@sigrun/sigrun 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/CONTEXT.md +163 -0
  3. package/LICENSE +21 -0
  4. package/README.md +691 -2
  5. package/THIRD_PARTY_NOTICES.md +28 -0
  6. package/dist/backup/marker.js +80 -0
  7. package/dist/backup/run-backup.js +339 -0
  8. package/dist/cli.js +952 -0
  9. package/dist/commands/expand.js +245 -0
  10. package/dist/commands/templates.js +272 -0
  11. package/dist/config.js +1146 -0
  12. package/dist/extension.d.ts +431 -0
  13. package/dist/harness/activity.js +42 -0
  14. package/dist/harness/ambient.js +261 -0
  15. package/dist/harness/auth.js +130 -0
  16. package/dist/harness/boot.js +865 -0
  17. package/dist/harness/builtins.js +191 -0
  18. package/dist/harness/event-bus.js +1203 -0
  19. package/dist/harness/health.js +30 -0
  20. package/dist/harness/identity/index.js +37 -0
  21. package/dist/harness/identity/invites.js +94 -0
  22. package/dist/harness/identity/json-store.js +108 -0
  23. package/dist/harness/identity/setup-code.js +81 -0
  24. package/dist/harness/identity/tokens.js +101 -0
  25. package/dist/harness/identity/users.js +93 -0
  26. package/dist/harness/interactions.js +283 -0
  27. package/dist/harness/loader.js +1236 -0
  28. package/dist/harness/memory-default.js +35 -0
  29. package/dist/harness/naming.js +193 -0
  30. package/dist/harness/packages.js +1002 -0
  31. package/dist/harness/providers/auth-bridge.js +60 -0
  32. package/dist/harness/providers/index.js +277 -0
  33. package/dist/harness/providers/models-store.js +105 -0
  34. package/dist/harness/providers/refresh.js +161 -0
  35. package/dist/harness/providers/scheduler.js +89 -0
  36. package/dist/harness/registry.js +9 -0
  37. package/dist/harness/reserved-sections.js +24 -0
  38. package/dist/harness/seam-host.js +239 -0
  39. package/dist/harness/sections/context-files.js +78 -0
  40. package/dist/harness/sections/model-guidance.js +77 -0
  41. package/dist/harness/settings.js +1053 -0
  42. package/dist/harness/stores.js +450 -0
  43. package/dist/harness/tools.js +500 -0
  44. package/dist/harness/workspace.js +73 -0
  45. package/dist/index.js +39 -0
  46. package/dist/jobs/docs.js +257 -0
  47. package/dist/jobs/fire.js +342 -0
  48. package/dist/jobs/index.js +228 -0
  49. package/dist/jobs/observe.js +165 -0
  50. package/dist/jobs/reconcile.js +245 -0
  51. package/dist/jobs/schedule.js +77 -0
  52. package/dist/jobs/spec.js +248 -0
  53. package/dist/jobs/task.js +190 -0
  54. package/dist/jobs/transfer.js +53 -0
  55. package/dist/serve.js +164 -0
  56. package/dist/server/auth/claim-lock.js +28 -0
  57. package/dist/server/auth/ip.js +51 -0
  58. package/dist/server/auth/login-limiter.js +52 -0
  59. package/dist/server/auth/loopback.js +47 -0
  60. package/dist/server/auth/pipeline.js +330 -0
  61. package/dist/server/auth/principal.js +26 -0
  62. package/dist/server/commands.js +198 -0
  63. package/dist/server/content-parts.js +142 -0
  64. package/dist/server/context.js +13 -0
  65. package/dist/server/conversation-handler.js +61 -0
  66. package/dist/server/conversation-ops.js +310 -0
  67. package/dist/server/events/hub.js +39 -0
  68. package/dist/server/events/types.js +8 -0
  69. package/dist/server/helpers.js +351 -0
  70. package/dist/server/host-bridge.js +131 -0
  71. package/dist/server/index.js +232 -0
  72. package/dist/server/notifications.js +185 -0
  73. package/dist/server/ownership.js +67 -0
  74. package/dist/server/readiness.js +36 -0
  75. package/dist/server/routes/auth.js +172 -0
  76. package/dist/server/routes/conversations.js +341 -0
  77. package/dist/server/routes/events.js +161 -0
  78. package/dist/server/routes/extension-routes.js +127 -0
  79. package/dist/server/routes/extensions.js +168 -0
  80. package/dist/server/routes/files.js +308 -0
  81. package/dist/server/routes/interactions.js +260 -0
  82. package/dist/server/routes/jobs.js +271 -0
  83. package/dist/server/routes/meta.js +146 -0
  84. package/dist/server/routes/metrics.js +219 -0
  85. package/dist/server/routes/onboarding.js +268 -0
  86. package/dist/server/routes/probes.js +53 -0
  87. package/dist/server/routes/providers.js +198 -0
  88. package/dist/server/routes/settings.js +48 -0
  89. package/dist/server/routes/stream.js +131 -0
  90. package/dist/server/routes/submissions.js +317 -0
  91. package/dist/server/routes/users.js +169 -0
  92. package/dist/server/schema-fixture.js +950 -0
  93. package/dist/server/session-meta.js +247 -0
  94. package/dist/server/static.js +169 -0
  95. package/dist/server/views.js +71 -0
  96. package/dist/skills/index.js +418 -0
  97. package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
  98. package/node_modules/@sigrun/shared/LICENSE +21 -0
  99. package/node_modules/@sigrun/shared/README.md +10 -0
  100. package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
  101. package/node_modules/@sigrun/shared/dist/access.js +72 -0
  102. package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
  103. package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
  104. package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
  105. package/node_modules/@sigrun/shared/dist/commands.js +25 -0
  106. package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
  107. package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
  108. package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
  109. package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
  110. package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
  111. package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
  112. package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
  113. package/node_modules/@sigrun/shared/dist/json.js +13 -0
  114. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
  115. package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
  116. package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
  117. package/node_modules/@sigrun/shared/dist/settings.js +19 -0
  118. package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
  119. package/node_modules/@sigrun/shared/dist/themes.js +156 -0
  120. package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
  121. package/node_modules/@sigrun/shared/dist/turns.js +166 -0
  122. package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
  123. package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
  124. package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
  125. package/node_modules/@sigrun/shared/dist/views.js +38 -0
  126. package/node_modules/@sigrun/shared/package.json +83 -0
  127. package/package.json +90 -5
  128. package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
  129. package/webui/dist/assets/index-rOiARZLF.js +50 -0
  130. package/webui/dist/index.html +37 -0
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Provider surface routes: `GET /v1/models` (the merged chat-model
3
+ * catalog as picker entries — no provider internals: baseUrl, headers,
4
+ * and auth stay server-side) and `GET /v1/providers` (the secret-free
5
+ * per-provider readiness report, spec §6.4/§7.6).
6
+ *
7
+ * Core hardening W1 Task 6 (spec §5.7): the readiness report MOVED here
8
+ * from `/v1/auth/check` so `/v1/auth` is API auth only — the old path
9
+ * serves the identical body as a deprecated alias for one release,
10
+ * marking its responses with a `Deprecation: true` header.
11
+ *
12
+ * Core hardening W3 Task 6 (spec §7.6) adds the MUTATIONS: `POST
13
+ * /v1/providers/:id/refresh` (one bounded refresh, the entry back),
14
+ * `PUT /v1/providers/:id/credential {apiKey}` (admin) and `DELETE …
15
+ * /credential` (admin) — writes through `opened.auth` (the bridge stays
16
+ * read-only; the ROUTE is the writer), then ONE refresh, then the entry.
17
+ * Restart-free by the credential-precedence design: the auth bridge
18
+ * resolves auth.json per use, so the same in-process catalog picks the
19
+ * new key up on the next refresh — no provider restart, no re-boot.
20
+ */
21
+ import { DEFAULT_AUTH_KEY } from "../../harness/auth.js";
22
+ import { providerAuthStatus } from "../helpers.js";
23
+ /**
24
+ * The per-provider readiness report (the shared derivation BOTH routes
25
+ * serve — one body, never two). The response carries ONLY a per-provider
26
+ * status (+ optional tracker detail) — no env values, no key material, no
27
+ * hints beyond the status itself (spec §6.4: "The API never accepts or
28
+ * returns credentials"). The status is a pure synchronous derivation over
29
+ * boot-retained state (same discipline as /v1/meta), per provider in
30
+ * catalog order (enumeration is over PROVIDERS — `getProviders()` — not
31
+ * models, so an empty-catalog boot contribution still appears):
32
+ *
33
+ * 1. readiness tracker registered by the owning contributor
34
+ * (`contribution.readiness`): reported verbatim — the three-state
35
+ * vocabulary `not_configured` (no credential resolved — an
36
+ * unconfigured optional plugin, not a degraded process) |
37
+ * `degraded` (configured, last refresh failed) | `ready` (refresh
38
+ * succeeded / static catalog), plus its optional `detail`.
39
+ * 2. contributor-owned WITHOUT a tracker: the legacy producer mapping
40
+ * is preserved — "ok" → `ready`, "degraded" → `invalid` (the §3
41
+ * union is exactly ok | degraded; "degraded" is the known-bad
42
+ * signal). A producer is per-contributor, not per-provider, so
43
+ * every provider a contributor owns shares its status. No producer
44
+ * → `not_ready` (nothing to verify).
45
+ * 3. env-degenerate provider (no contributor owns it — the collector's
46
+ * ownership map has no entry): the legacy env-presence mapping is
47
+ * preserved — `ready` iff a key resolves (`auth.json` `"default"`
48
+ * or the provider's own entry, or a non-empty `OPENAI_API_KEY` in
49
+ * the boot-retained provider env), else `not_ready`. (The
50
+ * degenerate provider reports §3 health "ok" by absence, so key
51
+ * presence is the honest readiness check for it; the three-state
52
+ * vocabulary applies to plugin trackers only.)
53
+ */
54
+ /** The per-request index of `opened.providerHealth` producers by name. */
55
+ function producersIndex(opened) {
56
+ return new Map(opened.providerHealth.map((producer) => [producer.name, producer]));
57
+ }
58
+ /**
59
+ * One provider's report entry: the readiness derivation PLUS the W3
60
+ * Task 6 enrichment (spec §7.6) — the catalog `models` count
61
+ * (`models.getModels(id)`, pi-ai's best-effort count) and the owning
62
+ * contributor `owner` from the collector's ownership map (absent for
63
+ * the env-degenerate provider). The same entry serves every provider
64
+ * route: the report, the refresh response, and the credential
65
+ * write/delete responses — one shape, never two.
66
+ */
67
+ function readinessEntry(opened, providerId, producersByName) {
68
+ const { status, detail } = providerAuthStatus(opened, providerId, producersByName);
69
+ const owner = opened.providerOwnership.get(providerId);
70
+ return {
71
+ provider: providerId,
72
+ status,
73
+ models: opened.models.getModels(providerId).length,
74
+ ...(owner === undefined ? {} : { owner }),
75
+ ...(detail === undefined ? {} : { detail }),
76
+ };
77
+ }
78
+ function providerReadinessReport(opened) {
79
+ // W2 Task 8's reconciliation: the per-provider derivation lives in ONE
80
+ // place — `providerAuthStatus` (helpers.ts, shared with the
81
+ // `sigrun_provider_ready{provider,status}` metric) — so this report and
82
+ // the metrics exposition can never drift. The response shape is W1's
83
+ // `/v1/providers` + deprecated-`/v1/auth/check` body plus the W3 Task 6
84
+ // enrichment (`models`, `owner`).
85
+ const producersByName = producersIndex(opened);
86
+ const providers = [];
87
+ for (const { id: providerId } of opened.models.getProviders()) {
88
+ providers.push(readinessEntry(opened, providerId, producersByName));
89
+ }
90
+ return { providers };
91
+ }
92
+ /** Register the model-catalog and provider-readiness routes. */
93
+ export function registerProviderRoutes(app, ctx) {
94
+ const { opened } = ctx;
95
+ // The readiness home (spec §5.7/§7.6): the secret-free per-provider
96
+ // report, served from the one shared derivation above.
97
+ app.get("/v1/providers", async () => {
98
+ return providerReadinessReport(opened);
99
+ });
100
+ // The deprecated alias (spec §5.7: "The old path stays as a deprecated
101
+ // alias for one release"): the identical body plus the RFC 8594-style
102
+ // `Deprecation` header so clients can see the move on the wire.
103
+ app.get("/v1/auth/check", async (_request, reply) => {
104
+ reply.header("Deprecation", "true");
105
+ return providerReadinessReport(opened);
106
+ });
107
+ // The `:id` prelude shared by every mutation route: the provider must
108
+ // exist in the catalog (`getProviders()` is the enumeration the report
109
+ // serves, so the 404 vocabulary matches it). Key material never
110
+ // appears in any of these routes — statuses and counts only.
111
+ const isKnownProvider = (providerId) => opened.models.getProvider(providerId) !== undefined;
112
+ // One bounded refresh of the named provider (W3 Task 6, spec §7.6):
113
+ // the SAME policy the boot/interval/refresh-on-miss paths run — bounded
114
+ // by the deadline, failures warned (never the route's error), a
115
+ // flipped provider publishes `providers.changed`. Not admin-gated
116
+ // (spec §7.6 gates the credential writes only).
117
+ app.post("/v1/providers/:id/refresh", async (request, reply) => {
118
+ const { id } = request.params;
119
+ if (!isKnownProvider(id)) {
120
+ return await reply.code(404).send({ error: "provider_not_found" });
121
+ }
122
+ await opened.refreshProvider(id);
123
+ return readinessEntry(opened, id, producersIndex(opened));
124
+ });
125
+ // The auth.json key a credential write targets (spec §7.6): the magic
126
+ // `"default"` for the DEFAULT model's provider (the precedence fix
127
+ // makes that the winning key for exactly that provider), the
128
+ // provider's own id otherwise.
129
+ const credentialKey = (providerId) => providerId === opened.defaultModel.provider
130
+ ? DEFAULT_AUTH_KEY
131
+ : providerId;
132
+ // Write the provider's credential (admin): store write → one refresh
133
+ // (the bridge resolves per use, so the new key is live with NO restart)
134
+ // → the entry. The body is `{apiKey: string}` — a non-empty string,
135
+ // nothing else (errors name the field, never the value).
136
+ app.put("/v1/providers/:id/credential", async (request, reply) => {
137
+ if (!request.principal.admin) {
138
+ return await reply.code(403).send({ error: "forbidden" });
139
+ }
140
+ const { id } = request.params;
141
+ if (!isKnownProvider(id)) {
142
+ return await reply.code(404).send({ error: "provider_not_found" });
143
+ }
144
+ const body = request.body;
145
+ if (typeof body !== "object" ||
146
+ body === null ||
147
+ Array.isArray(body)) {
148
+ return await reply.code(400).send({ error: "invalid_body" });
149
+ }
150
+ const apiKey = body.apiKey;
151
+ if (typeof apiKey !== "string" || apiKey === "") {
152
+ return await reply
153
+ .code(400)
154
+ .send({ error: "invalid_field", field: "apiKey" });
155
+ }
156
+ await opened.auth.setApiKey(credentialKey(id), apiKey);
157
+ await opened.refreshProvider(id);
158
+ return readinessEntry(opened, id, producersIndex(opened));
159
+ });
160
+ // Remove the provider's credential (admin): store delete of that SAME
161
+ // key → one refresh → the entry. Restart-free the same way — the
162
+ // tracker re-seeds `not_configured` on the refresh's auth resolution.
163
+ app.delete("/v1/providers/:id/credential", async (request, reply) => {
164
+ if (!request.principal.admin) {
165
+ return await reply.code(403).send({ error: "forbidden" });
166
+ }
167
+ const { id } = request.params;
168
+ if (!isKnownProvider(id)) {
169
+ return await reply.code(404).send({ error: "provider_not_found" });
170
+ }
171
+ await opened.auth.deleteApiKey(credentialKey(id));
172
+ await opened.refreshProvider(id);
173
+ return readinessEntry(opened, id, producersIndex(opened));
174
+ });
175
+ // Models: the merged chat-model catalog as picker entries (no provider
176
+ // internals — baseUrl/headers/auth stay server-side).
177
+ app.get("/v1/models", async () => {
178
+ const models = opened.models.getModels().map((model) => ({
179
+ id: `${model.provider}/${model.id}`,
180
+ provider: model.provider,
181
+ modelId: model.id,
182
+ name: model.name,
183
+ contextWindow: model.contextWindow,
184
+ maxTokens: model.maxTokens,
185
+ reasoning: model.reasoning,
186
+ input: model.input,
187
+ // Project the four rate fields explicitly (ModelCostRates); `tiers` is
188
+ // dropped for v1 (the picker doesn't use them) — no unsound cast.
189
+ cost: {
190
+ input: model.cost.input,
191
+ output: model.cost.output,
192
+ cacheRead: model.cost.cacheRead,
193
+ cacheWrite: model.cost.cacheWrite,
194
+ },
195
+ }));
196
+ return { models };
197
+ });
198
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Settings routes over the file-backed store on the harness's live
3
+ * settings (pi parity): GET serves the current effective settings
4
+ * (absent file = `{}` — every default); PATCH validates + merges +
5
+ * persists through the store.
6
+ */
7
+ import { SettingsValidationError } from "../../harness/settings.js";
8
+ import { settingsResponse } from "../helpers.js";
9
+ /** Register `GET`/`PATCH /v1/settings`. */
10
+ export function registerSettingsRoutes(app, ctx) {
11
+ const { opened, config, env } = ctx;
12
+ // PATCH semantics: the harness picks a change up on the next resolution
13
+ // (steeringMode on the next submission's admission, because
14
+ // pi-durable re-reads `HarnessSettings` per boundary). Errors follow
15
+ // the repo's PATCH conventions: 400 `invalid_field` naming the key
16
+ // (unknown key, wrong type), 400 `invalid_body` for a non-object.
17
+ app.get("/v1/settings", async () => {
18
+ return settingsResponse(opened, config, env);
19
+ });
20
+ app.patch("/v1/settings", async (request, reply) => {
21
+ // Admin-only (spec §5.6): PATCH edits `extensions[]`, which is code
22
+ // execution at the next boot — a non-admin (any authenticated
23
+ // token-method user, a non-admin proxy user) gets 403 before the
24
+ // body is even read. GET stays ungated (spec lists only PATCH).
25
+ if (!request.principal.admin) {
26
+ return await reply.code(403).send({ error: "forbidden" });
27
+ }
28
+ const body = request.body;
29
+ if (body === undefined ||
30
+ body === null ||
31
+ typeof body !== "object" ||
32
+ Array.isArray(body)) {
33
+ return await reply.code(400).send({ error: "invalid_body" });
34
+ }
35
+ try {
36
+ opened.settings.update(body);
37
+ return settingsResponse(opened, config, env);
38
+ }
39
+ catch (error) {
40
+ if (error instanceof SettingsValidationError) {
41
+ return await reply
42
+ .code(400)
43
+ .send({ error: "invalid_field", field: error.field });
44
+ }
45
+ throw error;
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The SSE view stream route: `GET /v1/conversations/:id/stream`. The
3
+ * conversation resolves through the `loadConversation` pre-handler, so the
4
+ * unknown-id 404 lands as plain JSON BEFORE the handler hijacks the reply —
5
+ * a hijacked stream has no error event. This endpoint is exercised over a
6
+ * REAL socket in tests: it writes raw frames, which `app.inject()` cannot
7
+ * observe (light-my-request settles only on `end()`).
8
+ */
9
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
10
+ import { loadConversation } from "../conversation-handler.js";
11
+ import { ID_PARAMS_SCHEMA } from "../helpers.js";
12
+ import { serializeConversationView, } from "../views.js";
13
+ import { diffViews, isEmptyDelta, } from "@sigrun/shared/view-delta";
14
+ /** Register the SSE view stream route. */
15
+ export function registerStreamRoutes(app, ctx) {
16
+ // SSE view stream (spec §5 "Views + watch frames over SSE", §7.3 ruling
17
+ // 11): one Server-Sent Events stream per conversation, hand-rolled over
18
+ // `reply.raw` (no @fastify/sse dependency). Event shape: `event: snapshot`
19
+ // with the wire view on connect, then per watch revision the diff against
20
+ // the last-sent view: a representable change rides as `event: delta`
21
+ // (entry append/remove + changed docs; empty deltas are skipped), a
22
+ // NON-representable one (a compaction's new head marker, any reorder)
23
+ // falls back to a fresh `event: snapshot` — so a reconnect's snapshot and
24
+ // the fallback are the same frame, and the client's rule stays
25
+ // "snapshot REPLACES, delta folds" (see the Appendix C
26
+ // named-verification record). `Last-Event-ID` is therefore accepted and
27
+ // deliberately ignored. Heartbeat: a `: ping` comment every 15s (proxy/
28
+ // LB idle-timeout survival). Lifecycle: exactly one owner (`finish` below)
29
+ // stops the watch and clears the interval on client disconnect or watch
30
+ // termination.
31
+ app.get("/v1/conversations/:id/stream", { schema: { params: ID_PARAMS_SCHEMA }, preHandler: loadConversation(ctx) }, async (request, reply) => {
32
+ const conversation = request.conversation;
33
+ // Acquire the watch BEFORE hijacking: an acquisition failure then
34
+ // lands on Fastify's fail-loud 500 path — a hijacked stream has no
35
+ // error event. `value` is the acquisition revision (the snapshot
36
+ // source); frames published between here and start() are held by the
37
+ // watch's bounded pending delivery, so nothing is lost.
38
+ const watch = await conversation.watch(BACKGROUND_CONTEXT);
39
+ // Watcher bookkeeping (W5 Task 6, spec §9.6): one attached client.
40
+ // The exactly-once `finish` below decrements — the notifications
41
+ // surface's `"unwatched"` runSettled rule reads this count.
42
+ const watchers = ctx.watchersByConversation;
43
+ watchers.set(conversation.id, (watchers.get(conversation.id) ?? 0) + 1);
44
+ reply.hijack();
45
+ const raw = reply.raw;
46
+ raw.writeHead(200, {
47
+ "content-type": "text/event-stream",
48
+ "cache-control": "no-cache",
49
+ "connection": "keep-alive",
50
+ "x-accel-buffering": "no",
51
+ });
52
+ raw.flushHeaders();
53
+ // Socket-level write errors after teardown have no consumer (the
54
+ // close path owns teardown); swallow them so a mid-write disconnect
55
+ // cannot crash the process with an unhandled 'error' event.
56
+ raw.on("error", () => { });
57
+ let nextId = 1;
58
+ const send = (event, payload) => {
59
+ // Single data line: JSON.stringify escapes newlines in strings.
60
+ raw.write(`id: ${nextId++}\nevent: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
61
+ };
62
+ let finished = false;
63
+ const heartbeat = setInterval(() => {
64
+ raw.write(": ping\n\n");
65
+ }, 15_000);
66
+ // The single cleanup owner: client disconnect (response close =
67
+ // socket termination — NOT `request.raw.on("close")`, which on
68
+ // Node 26 fires at request-message completion, i.e. immediately for
69
+ // a bodyless GET) or watch termination (`watch.closed`). The flag
70
+ // makes it exactly-once even though both paths can race.
71
+ const finish = (fromWatchEnd) => {
72
+ if (finished)
73
+ return;
74
+ finished = true;
75
+ clearInterval(heartbeat);
76
+ // Watcher bookkeeping (W5 Task 6): the paired decrement — exactly
77
+ // once per increment by the `finished` guard, floored at removal
78
+ // so a stray double path can never drive the count negative.
79
+ const remaining = (watchers.get(conversation.id) ?? 1) - 1;
80
+ if (remaining <= 0)
81
+ watchers.delete(conversation.id);
82
+ else
83
+ watchers.set(conversation.id, remaining);
84
+ if (!fromWatchEnd) {
85
+ // stop() is idempotent upstream; fire-and-forget because the
86
+ // stream is ending regardless and a stop() rejection after a
87
+ // dead socket has no consumer to fail loud to.
88
+ void watch.stop().catch(() => { });
89
+ }
90
+ raw.end();
91
+ };
92
+ raw.on("close", () => finish(false));
93
+ // Upstream resolves `closed` with a WatchEnd for every terminal
94
+ // state (failures included); a rejection would be an upstream
95
+ // contract violation — catch it so one stream's anomaly ends that
96
+ // stream instead of taking the process down as an unhandled
97
+ // rejection.
98
+ void watch.closed.then(() => finish(true), () => finish(true));
99
+ // The client may have disconnected while the watch was being
100
+ // acquired (before the close listener existed): `destroyed` covers
101
+ // that window — stop the just-acquired watch instead of leaking it.
102
+ if (raw.destroyed) {
103
+ finish(false);
104
+ return;
105
+ }
106
+ // Snapshot FIRST (from the acquisition revision), then install the
107
+ // listener — pending frames flow after the snapshot because start
108
+ // never invokes the listener inline. `prev` is the last-sent wire
109
+ // view the diff loop measures against.
110
+ let prev = serializeConversationView(watch.value);
111
+ send("snapshot", prev);
112
+ watch.start(async (value) => {
113
+ if (finished)
114
+ return; // the stream is over; drop the frame
115
+ const next = serializeConversationView(value);
116
+ const delta = diffViews(prev, next);
117
+ prev = next;
118
+ // Not representable as entries append/remove + changed docs (a
119
+ // reorder or a compaction's new head marker) → a fresh snapshot.
120
+ if (delta === undefined) {
121
+ send("snapshot", next);
122
+ return;
123
+ }
124
+ // Nothing changed on the wire (a revision the serializer flattens
125
+ // away) — skip the frame entirely.
126
+ if (isEmptyDelta(delta))
127
+ return;
128
+ send("delta", delta);
129
+ });
130
+ });
131
+ }