Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

A gateway sends fleet events to Slack

Open
#720 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
50/100
Issue type
Feature
Clarity
Clearly specified
Activity status
Active
Tech stack
typescript
Domain
backend

Research direction

Start with src/alerts/alerts.test.ts and the createAlerts extension point from #719, then read src/alerts/AGENTS.md, src/alerts/, src/core/config.ts, and src/daemon/main.ts. Implement the Slack format and worker label lookup described in the issue, updating the protocol and docs as listed. Done means the specified message cases, escaping, retry behavior, config validation, and protocol tests pass.

Written by the indexing model from the issue text.

Description

task:ready

Part of #707.

Scope

A destination can be format: "slack". The gateway posts one written message per event, naming the worker by its label, through the same queue, tries and limits as generic. After this PR, an operator gets readable messages in Slack. Other services that take Slack's incoming-webhook body may work; only Slack is promised.

In short

A slack destination gets {"text": "<message>"} for each event it lists. Each of the eleven events has one message, written below word for word. A slack destination takes no secret.

flowchart LR
  E[Fact on the gateway's bus] --> Q[destination queue]
  Q --> F{format}
  F -->|generic| G[signed envelope]
  F -->|"slack (new)"| S["{text: message}, worker named by label"]

Messages. <worker> is the worker's label from the gateway's view of it, or its id when it has no label or the gateway has no view of it. Variable parts are written as <name>.

Event Message
worker.connected with version 🟢 Worker <worker> connected (simlock <version>).
worker.connected without version 🟢 Worker <worker> connected.
worker.disconnected, leaseCount 0 🔴 Worker <worker> disconnected.
worker.disconnected, leaseCount 1 🔴 Worker <worker> disconnected while holding 1 lease.
worker.disconnected, leaseCount n > 1 🔴 Worker <worker> disconnected while holding <n> leases.
worker.rejected ⛔ A worker was refused (<reason>): <claimed>. then, when count > 1, <count> more refusals like it came before this one. <claimed> is the payload's label, else its workerId, else an unknown worker; never the registry.
worker.drain-started ⏸ Worker <worker> is drained: it takes no new leases.
worker.drain-ended ▶️ Worker <worker> takes new leases again.
device.quarantine-abandoned ⚠️ Device <deviceId> on <worker> was retired after <attempts> failed clean-ups.
device.quarantine-stranded 🛑 Device <deviceId> on <worker> could not be cleaned up or deleted after <attempts> tries: <error>. It needs a person.
device.recovery-failed ⚠️ Device <deviceId> on <worker> crashed during lease <leaseId> and could not be brought back: <reason>.
device.stalled-transition-detected ⏳ Device <deviceId> on <worker> has been <state> for <age>.
disk.pressure-detected 💾 Worker <worker> is low on disk: <freeBytes> free (threshold <threshold>).
component.install-failed ❌ Installing <platform> <componentId> on <worker> failed: <error>.
  • <age> from ageMs: under 60 000 ms → whole seconds, 45 s; under 3 600 000 → whole minutes, 14 min; else 1 h 5 min (minutes left out when 0: 2 h). Rounded down.
  • <freeBytes>, <threshold>: GiB (1024³ bytes), as diskPressure.freeBytesThreshold is documented, one decimal rounded half up, .0 dropped: 8 804 682 956 → 8.2 GiB, 10 737 418 240 (the default threshold) → 10 GiB. Under 1 GiB (counted on the raw bytes) → MiB the same way: 536 870 912 → 512 MiB.
  • Every variable part, the worker's name included, is first cut to 300 characters (counted as Unicode code points; a multi-code-point emoji such as ⚠️ can still be cut in two, which only changes how that last character looks) with … added when longer, then has &, <, > written as &amp;, &lt;, &gt; (Slack's escaping). A label like <!channel> therefore never pings a channel.
  • The message is built when it leaves the queue for its first try (#719's rule, with its test), naming the worker as the gateway knows it then. Later tries resend the same body.
  • Known gap (decided in the spec session): a worker's relayed payload is not checked. The gateway replaces one that is not an object with {workerId} (F11), so a message is always built. A missing field prints as undefined; <age>, <freeBytes> and <threshold> print ? when not a finite number. The module manual lists this under Known gaps.

Inputs:

Destination Today (after #719) After
format: "slack", url, events expected one of "generic" accepted
format: "slack" with any secret key ("s", "", null) expected one of "generic" expected no secret: a slack destination is not signed (path …secret)
format: "generic" without secret expected a non-empty secret for a generic destination the same
format: "teams" expected one of "generic" expected one of "generic", "slack"
format: "slack", url with a user name URL error the same URL error (#719)

If this goes wrong, a message names the wrong worker, misses an event, pings a whole channel, or breaks Slack's formatting.

Technical spec

Facts

Commit ccb50353.

# Fact Source
F1 Payloads: worker.connected {workerId, label?, version?}, worker.disconnected {workerId, label?, leaseCount}, worker.rejected {reason: "forbidden" | "unauthenticated", workerId?, label?, count?}; workerId and label on worker.rejected are what the connection claimed. src/bus/index.ts:296-338
F2 Payloads: device.recovery-failed {deviceId, leaseId, attempts, reason, error}, device.quarantine-abandoned {deviceId, attempts}, device.quarantine-stranded {deviceId, attempts, error}. src/bus/index.ts:147-176
F3 Payloads: component.install-failed {platform, componentId, durationMs, error, requesterId?}, disk.pressure-detected {freeBytes, threshold}, device.stalled-transition-detected {deviceId, platform, state, ageMs, thresholdMs}. src/bus/index.ts:223-263
F4 worker.drain-started / worker.drain-ended carry workerId and label?. src/gateway/worker-registry.ts:474; docs/EVENTS.md:111-112
F5 A relayed worker event has workerId in its payload. src/gateway/worker-link.ts:533
F6 WorkerRegistry.view(workerId) returns the view or undefined; the view holds label when the worker set one. GatewayService.workers is the registry. src/gateway/worker-registry.ts:151, :244; src/gateway/service.ts:118
F7 startGatewayDaemon holds gatewayService before the dispatcher is built. src/daemon/main.ts:740-850
F8 Slack incoming webhooks take {"text": ...} and need &, <, > escaped in text; <!channel> in text pings the channel. Slack API docs, "Sending messages using incoming webhooks" and "Formatting text for app surfaces: escaping text"; Slack's service has no public source, so its docs are the source
F9 No exported byte or duration formatter outside src/cli; src/core/cleanup/idle-destroy.ts has a private GiB one. diskPressure.freeBytesThreshold defaults to 10 * 1024 ** 3 and is documented as 10 GiB. src/core/cleanup/idle-destroy.ts:66-69; src/core/config.ts:753; docs/CONFIGURATION.md:64
F11 A worker's claimed workerId and label come from headers it sends before authentication; a relayed payload is z.unknown() and is not checked by the gateway. src/ports/uplink-websocket.ts:220-239; src/contract/schemas.ts:784; src/gateway/worker-link.ts:532-534 (isRecord(payload) ? {...payload, workerId} : {workerId}), :622-624
F10 #719's createAlerts, queue, and config validator, and the Shared values rows of #707. #719
Modules touched

Searches: ALERT_EVENTS, createAlerts, configSchema gateway.alerts, workers.view.

  • src/core/config.ts: format adds "slack"; secret refused on slack, required on generic.
  • src/contract/schemas.ts: format: z.enum(["generic", "slack"]); secret is already optional (#719). PROTOCOL_VERSION_RANGE and every test that pins the current number (search the number itself and "protocol " in src/, e.g. src/contract/protocol.test.ts, src/daemon/server.test.ts, src/daemon-protocol/index.test.ts, src/gateway/service.test.ts): bump by one, min with it.
  • src/alerts/formats/slack/ (new), one line in ALERT_FORMAT_REGISTRY, "slack" in core's ALERT_FORMATS and ALERT_FORMAT_SECRET ("refused"); in src/alerts/: a message table Record<AlertEventName, …>; createAlerts gains workerLabel(workerId: string): string | undefined, and alerts falls back to the id when it answers undefined; the manual.
  • src/daemon/main.ts: passes workerLabel: (id) => gatewayService.workers.view(id)?.label.
  • src/alerts/AGENTS.md: the message table rule and the Known gap above.
  • Docs: docs/CONFIGURATION.md (slack format, the messages table, Slack as the promised service).
flowchart LR
  main["daemon/main.ts *"] --> AL["alerts/ *"]
  main -. "workerLabel" .-> REG[gateway WorkerRegistry]
  AL --> CFG["core/config.ts *"]
  CS["contract/schemas.ts *"]
Surface and extension points

createAlerts options gain workerLabel(workerId: string): string | undefined; the fallback to the id is inside alerts, so its tests see it. A new directory and one registry line, as #719's extension point asks; the queue does not change. The conformance suite runs on slack too.

Contract and event changes
  • config.get output: format may be "slack", with no secret. This PR bumps the protocol by one, min with it; the PR that merges second among the open tasks bumping this line (today #713, #714, #629-#632, #635-#637, #719, #721) bumps again.
  • No event change.
Other code on the same state

Searches: view( on WorkerRegistry (6 hits), ALERT_EVENTS (#719).

  • The registry writes a view's label at connect; workerLabel only reads. A worker removed before its message is formatted falls back to the id.
  • #719's queue and tries carry slack messages unchanged.
Failure modes

Same as #719 for the POST. A missing optional field takes the shorter message above. A malformed relayed payload is the Known gap above: a message with undefined or ? in it.

Rules in play
  • safety, "wire input is a claim": worker.rejected names what the connection claimed, never a registry worker; every variable part is escaped and cut.
  • architecture, a module hands out a capability, not its parts: alerts gets workerLabel, not the registry.
  • ADR 0035 §5.
Tests

Seam: createAlerts with a real EventBus, FakeClock, FakeWebhookPoster, in src/alerts/alerts.test.ts (#719's file); config through loadConfig in src/core/config.test.ts.

  • a slack destination posts {"text": message} with only the Content-Type and User-Agent headers
  • each of the eleven events posts its message word for word (one case per row of the In short table, worker.connected and worker.disconnected with each variant)
  • names the worker by the label workerLabel answers, and by the id when it answers undefined (cases: a label; undefined)
  • builds a message when it leaves the queue and resends the same body on later tries (the label changes while the message waits: the first try carries the new label; it changes again between try 1 and try 2: try 2's body equals try 1's)
  • worker.rejected names the claimed label, else the claimed id, else an unknown worker, and adds the count only above one (cases: label; id only; neither; count 1; count 4)
  • formats an age in seconds, minutes, and hours with minutes (45 000 → 45 s; 840 000 → 14 min; 3 900 000 → 1 h 5 min; 7 200 000 → 2 h; 59 999 → 59 s)
  • formats bytes in GiB and MiB with one decimal rounded half up, dropping .0 (8 804 682 956 → 8.2 GiB; 10 737 418 240 → 10 GiB; 536 870 912 → 512 MiB; 1 020 054 733 → 972.8 MiB)
  • cuts every variable part to 300 code points, then escapes &, < and > (cases: an error text <b>&; a registry label <!channel>; a claimed worker.rejected label <!here>; a 301-character error; a label whose 300th code point is an emoji)
  • a relayed payload with missing fields posts the text the Known gap names (cases: device.quarantine-abandoned with no attempts → …retired after undefined failed clean-ups.; disk.pressure-detected with no freeBytes → …low on disk: ? free (threshold 10 GiB).; a payload replaced by {workerId} for device.stalled-transition-detected → …has been undefined for ?.)
  • redactConfig masks a slack destination's url and adds no secret key (extends #719's case)
  • config.get's schema keeps a slack destination without a secret (extends #719's deep-equal case)
  • a slack destination is tried again on 5xx like a generic one (one case: #719 proves the rest of delivery)
  • loadConfig accepts a slack destination, refuses a secret on it, and lists both formats in the format message (the In short inputs table)
  • a worker joining a gateway with a slack destination makes a loopback receiver get its connected message naming the worker's label (extends #719's case in gateway-fleet.e2e.test.ts; proves main.ts passes the registry's label)

Done when

  • CI is green with the lines above.
  • In gateway-fleet.e2e.test.ts, a loopback receiver for a slack destination gets 🟢 Worker <label> connected (simlock 1.0.0-e2e). with the worker's label.
  • docs/CONFIGURATION.md lists the slack format, promises Slack only ("other services that take Slack's incoming-webhook body may work"), and gives the message for each event.

Out of scope

  • Slack Block Kit, threads, mentions, links to the console.
  • Teams, PagerDuty, email.

Depends on

  • #719

Written by an agent.

Dominant language
TypeScript
Stars
20
Forks
2
Avg merge
8h 17m
Merged PRs (30d)
273

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from callstackincubator/simlock

All issues in callstackincubator/simlock

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.