@sigrun/sigrun 0.0.0-stage → 0.1.1

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 +214 -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 +89 -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,1203 @@
1
+ /**
2
+ * The event bus (memory-seam Tasks 2 + 4): commit observation + bounded
3
+ * side lane + idle detection + shutdown sequencing. See
4
+ * docs/superpowers/specs/2026-10-15-sigrun-memory-seam.md §"Design".
5
+ *
6
+ * The bus subscribes to pi-durable's `Session.subscribeCommits` (sigrun's
7
+ * harness IS a Session) and reduces each `CommitPublication` into the
8
+ * memory seam's host events — settled-run groups and conversation
9
+ * lifecycle — without touching run logic. The upstream listener contract
10
+ * ("must not block or call Session APIs", types.d.ts) shapes everything
11
+ * here: the listener only FILTERS AND ENQUEUES; all handler work happens
12
+ * on the single drain loop (`drain()`), where every handler call is
13
+ * deadline-bounded (`Promise.race` against `hookDeadlineMs`), rejections
14
+ * are caught + logged, and overflow drops the OLDEST queued event.
15
+ *
16
+ * Detection rules (verified against pi-durable 1.2.0's publication shapes
17
+ * — table changes are the raw `StorageWrite` records; document changes are
18
+ * `DocumentCommitChange`s whose `record.createdAt === publication.seq`
19
+ * marks creation, `value === null` marks retirement):
20
+ *
21
+ * - **Settle**: a `submission` table write with `value.type === "input"`
22
+ * and a terminal status (`done`/`unanswered`). Write-type submissions
23
+ * never run and never settle-emit. Input submissions settle exactly once
24
+ * per run — the retry path keeps the task running (only exhaustion or a
25
+ * final answer settles) — and all of a run's inputs settle in ONE commit
26
+ * (`endRun`), so one publication coalesces per conversation into one
27
+ * `SettleGroup` sorted by submission id (ids ascend in admission order).
28
+ * - **Created/Forked**: a `conversation` table write. Every creation fires
29
+ * `conversationCreated` — a fork child is a new conversation too — and a
30
+ * record carrying `parent` additionally fires `conversationForked` with
31
+ * the fork point taken from the record.
32
+ * - **Archived**: `document` changes on the session-meta doc. Creation
33
+ * seeds a per-conversation flip baseline WITHOUT firing; ordinary
34
+ * changes fire only on boolean flips of `archived`. A fork's
35
+ * `document.copy` seeds the child's baseline from the fork parent's
36
+ * (`fork: "current"` — the child starts from the parent's current
37
+ * state), so inherited archive NEVER fires (the spec delivers that edge
38
+ * via `conversationForked`). A doc change with no baseline (the doc
39
+ * predates this process) compares against the materialization default
40
+ * `false` — archive detection stays commit-driven, nothing to re-arm.
41
+ *
42
+ * Idle detection (Task 4, spec §2's corrected ruling): idle = NO input
43
+ * admission AND no live run. The listener records admissions (`submission`
44
+ * writes with `type === "input"` + `status === "placed"` — a placement
45
+ * both stamps the admission clock and marks the run live; write-type
46
+ * submissions never touch either) and run ends (the settle statuses
47
+ * decrement the live-run count). A host-owned sweep interval (`idleSweepMs`,
48
+ * default 1s) then fires `conversationIdle` for every conversation whose
49
+ * last admission is at least `idleThresholdMs` old, has no live run, and
50
+ * has not already fired for its current quiet period (one fire per quiet
51
+ * period; a later admission re-arms). The sweep NEVER fires mid-run — the
52
+ * live-run gate holds even far past the threshold (review focus 6).
53
+ * Never-run conversations (created, never admitted — no durable anchor)
54
+ * stay non-idle; documented and accepted in the spec. All idle state is
55
+ * in-process: a restart forgets prior quiet periods (missed idle events
56
+ * are acceptable per the spec's at-least-once posture). Archive prunes a
57
+ * conversation's idle state (archive is stronger idle, with its own
58
+ * event); an un-archived conversation re-enters the idle clock only at
59
+ * its next admission (conservative — misses idles, never invents them).
60
+ */
61
+ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
62
+ import { AssistantEntry, UserEntry, } from "@earendil-works/pi-durable";
63
+ import { SessionMetaDoc } from "../server/session-meta.js";
64
+ import { EXPANDED_ENTRY_KIND } from "../commands/expand.js";
65
+ import { InteractionDoc, TERMINAL as INTERACTION_TERMINAL } from "./interactions.js";
66
+ import { AmbientDoc } from "./ambient.js";
67
+ /** Project a SessionMeta doc value into the tracked basis. */
68
+ function metaBasisOf(value) {
69
+ const basis = {
70
+ archived: value.archived === true,
71
+ pinned: value.pinned === true,
72
+ };
73
+ if (value.name !== undefined)
74
+ basis.name = value.name;
75
+ if (value.autoName !== undefined)
76
+ basis.autoName = value.autoName;
77
+ if (value.lastActivityAt !== undefined)
78
+ basis.lastActivityAt = value.lastActivityAt;
79
+ if (value.owner !== undefined)
80
+ basis.owner = value.owner;
81
+ if (value.sharing !== undefined)
82
+ basis.sharing = JSON.stringify(value.sharing);
83
+ return basis;
84
+ }
85
+ /** How long shutdown waits for in-flight worker runs before abandoning (spec §3). */
86
+ const WORKER_SHUTDOWN_AWAIT_MS = 30_000;
87
+ /**
88
+ * The commit-observation side lane. One instance per harness; open after
89
+ * boot, close before the harness closes (`shutdownSequence` for the
90
+ * ordered path, `close` otherwise — boot also wires `subscribeClose` to
91
+ * `close` so a failed-session teardown cannot leak the sweep timer).
92
+ */
93
+ export class EventBus {
94
+ /** Subscribe `harness`'s commit stream + start the idle sweep when idle options are given. */
95
+ static open(harness, options) {
96
+ return new EventBus(harness, options);
97
+ }
98
+ #unsubscribe;
99
+ #harness;
100
+ #queueMax;
101
+ #hookDeadlineMs;
102
+ #idleThresholdMs;
103
+ #queue = [];
104
+ #settleHandlers = [];
105
+ #lifecycleHandlers = [];
106
+ /** Extension name → its handlers (the loader-validated factory-result fields). */
107
+ #extensions = new Map();
108
+ /** Per-conversation `archived` flip baseline (the last value a change was measured against). */
109
+ #archivedBasis = new Map();
110
+ /** Per-conversation SessionMeta summary baseline (W3 Task 4): the last
111
+ * tracked summary values a `conversation.changed` diff was measured
112
+ * against. `#archivedBasis` stays the archive-flip baseline; this is
113
+ * the generalized form for the remaining summary fields. */
114
+ #metaBasis = new Map();
115
+ /** The host-event listeners (W3 Task 4): fed SYNCHRONOUSLY from the
116
+ * commit reduction — the contract is no-blocking-work (hub-side
117
+ * delivery concerns live in the hub). */
118
+ #hostListeners = [];
119
+ /** Per-conversation wall clock of the last input admission (the idle clock's anchor). */
120
+ #lastAdmission = new Map();
121
+ /** Per-conversation count of placed-but-unsettled inputs (live runs). */
122
+ #liveInputs = new Map();
123
+ /** Per-conversation time of the last idle fire (one fire per quiet period). */
124
+ #idleFiredAt = new Map();
125
+ /** The host-owned idle sweep, when idle detection is on. */
126
+ #sweep;
127
+ /** The single drain loop, when one is running. */
128
+ #loop;
129
+ /** The worker states, in registration order (memory-seam Task 5). */
130
+ #workers = [];
131
+ #workerDeadlineMs;
132
+ #closed = false;
133
+ constructor(harness, options) {
134
+ this.#harness = harness;
135
+ this.#queueMax = options.queueMax;
136
+ this.#hookDeadlineMs = options.hookDeadlineMs;
137
+ this.#idleThresholdMs = options.idleThresholdMs;
138
+ this.#workerDeadlineMs = options.workerDeadlineMs;
139
+ if (options.onHostEvent !== undefined) {
140
+ this.onHostEvent(options.onHostEvent);
141
+ }
142
+ this.#unsubscribe = harness.subscribeCommits((publication) => {
143
+ this.observe(publication);
144
+ });
145
+ // The idle sweep is host-owned machinery (never an extension factory
146
+ // concern — the twill lesson) and binds to THIS bus's lifetime: close
147
+ // and shutdownSequence both clear it. Unref'd so a booted-but-idle
148
+ // process never hangs on it.
149
+ if (this.#idleThresholdMs !== undefined &&
150
+ options.idleSweepMs !== undefined &&
151
+ options.idleSweepMs > 0) {
152
+ this.#sweep = setInterval(() => {
153
+ if (this.sweepIdle() > 0) {
154
+ void this.drain(this.#hookDeadlineMs, BACKGROUND_CONTEXT);
155
+ }
156
+ }, options.idleSweepMs);
157
+ this.#sweep.unref?.();
158
+ }
159
+ }
160
+ /**
161
+ * Subscribe to the host-event surface (W3 Task 4, spec §7.4): every
162
+ * event the commit reduction derives — `conversation.created` /
163
+ * `conversation.changed` (SessionMeta summary diffs) / `run.admitted` /
164
+ * `run.settled` / `interaction.pending` / `interaction.settled` /
165
+ * `ambient.changed` — delivered SYNCHRONOUSLY inside the commit
166
+ * observation. The listener contract: NO blocking work, no Session
167
+ * API calls (the hub's delivery is its own side lane). Returns the
168
+ * unsubscribe.
169
+ */
170
+ onHostEvent(listener) {
171
+ this.#hostListeners.push(listener);
172
+ return () => {
173
+ const index = this.#hostListeners.indexOf(listener);
174
+ if (index >= 0)
175
+ this.#hostListeners.splice(index, 1);
176
+ };
177
+ }
178
+ /** Emit one host event to every listener; a throwing listener is logged, never propagated into the Session listener. */
179
+ #emitHost(event) {
180
+ for (const listener of this.#hostListeners) {
181
+ try {
182
+ listener(event);
183
+ }
184
+ catch (error) {
185
+ console.error("event-bus: host-event listener failed:", error);
186
+ }
187
+ }
188
+ }
189
+ /**
190
+ * Run one call under the SAME deadline bound every extension handler
191
+ * call runs under (W5 Task 6, spec §9.6): the notifications surface's
192
+ * side lane — host-owned delivery concerns kicked from the event hub,
193
+ * never bus queue events (so they must not ride the drain loop). The
194
+ * promise ALWAYS resolves: a throwing or over-deadline call is logged
195
+ * and abandoned, exactly like an extension handler's.
196
+ */
197
+ runBounded(label, call) {
198
+ return this.#bounded(label, call);
199
+ }
200
+ /** Register a settled-run handler (install order = run order); returns its unsubscribe. */
201
+ onSettled(handler) {
202
+ this.#settleHandlers.push(handler);
203
+ return () => {
204
+ const index = this.#settleHandlers.indexOf(handler);
205
+ if (index >= 0)
206
+ this.#settleHandlers.splice(index, 1);
207
+ };
208
+ }
209
+ /** Register a lifecycle handler (install order = run order); returns its unsubscribe. */
210
+ onLifecycle(handler) {
211
+ this.#lifecycleHandlers.push(handler);
212
+ return () => {
213
+ const index = this.#lifecycleHandlers.indexOf(handler);
214
+ if (index >= 0)
215
+ this.#lifecycleHandlers.splice(index, 1);
216
+ };
217
+ }
218
+ /**
219
+ * Register one loaded extension's handlers under its FINAL resolved
220
+ * name (boot calls this per loaded extension after `Harness.open`).
221
+ * A same-name registration replaces the previous one (the reinstall
222
+ * path); boot merges the loader's runSettled + lifecycle bindings into
223
+ * ONE object per name before registering. Returns its unsubscribe.
224
+ */
225
+ registerExtension(name, handlers) {
226
+ // Task 5: a same-name registration REPLACES — stop the previous
227
+ // workers before installing the new ones so no stale timer survives.
228
+ this.#stopExtensionWorkers(name);
229
+ this.#workers = this.#workers.filter((state) => state.extension !== name);
230
+ this.#extensions.set(name, handlers);
231
+ for (const worker of handlers.workers ?? []) {
232
+ this.#addWorker(name, worker);
233
+ }
234
+ return () => {
235
+ // Identity-guarded: a STALE unsubscribe (a same-name registration
236
+ // has since replaced this one) is a no-op — it must not delete the
237
+ // current registration NOR stop its workers. Both sides key on the
238
+ // exact handlers object this registration installed.
239
+ if (this.#extensions.get(name) !== handlers)
240
+ return;
241
+ this.#extensions.delete(name);
242
+ this.#stopExtensionWorkers(name);
243
+ this.#workers = this.#workers.filter((state) => state.extension !== name);
244
+ };
245
+ }
246
+ /** Register one worker (no timer — the `every` path was retired by ruling 12; wakes are the only trigger). */
247
+ #addWorker(extension, worker) {
248
+ const state = {
249
+ extension,
250
+ worker,
251
+ deadlineMs: this.#workerDeadlineMs,
252
+ running: false,
253
+ stopped: this.#closed,
254
+ pendingWake: undefined,
255
+ inflight: undefined,
256
+ lastRunAt: undefined,
257
+ lastError: undefined,
258
+ };
259
+ this.#workers.push(state);
260
+ }
261
+ /** Stop every worker of one extension (in-flight runs abandoned host-side). */
262
+ #stopExtensionWorkers(extension) {
263
+ for (const state of this.#workers) {
264
+ if (state.extension !== extension)
265
+ continue;
266
+ state.stopped = true;
267
+ state.pendingWake = undefined;
268
+ }
269
+ }
270
+ /**
271
+ * One idle sweep (spec §2): enqueue a `conversationIdle` event for every
272
+ * conversation whose last input admission is at least the threshold old,
273
+ * that has NO live run (the gate that holds even far past the threshold —
274
+ * review focus 6), and that has not already fired for its current quiet
275
+ * period (a later admission re-arms). Never-run conversations have no
276
+ * admission recorded and stay non-idle. Returns how many events were
277
+ * enqueued (the sweep's own drain call is skipped when zero).
278
+ */
279
+ sweepIdle(now = Date.now()) {
280
+ if (this.#closed || this.#idleThresholdMs === undefined)
281
+ return 0;
282
+ // Prune-on-archive (post-ship review item): an archived conversation
283
+ // stops firing conversationIdle — archive is the stronger idle, with
284
+ // its own event. The listener keys the prune on the session-meta
285
+ // flip basis (#archivedBasis), the one place archive state is
286
+ // already tracked; a conversation with no flip record yet
287
+ // (materialization default false, or a pre-process doc) is treated
288
+ // as un-archived. Archived(false) restores NOTHING — the
289
+ // conversation re-enters the idle clock at its next admission
290
+ // (conservative: misses idles, never invents them).
291
+ for (const conversationId of [...this.#lastAdmission.keys()]) {
292
+ if (this.#archivedBasis.get(conversationId) === true) {
293
+ this.#lastAdmission.delete(conversationId);
294
+ this.#liveInputs.delete(conversationId);
295
+ this.#idleFiredAt.delete(conversationId);
296
+ }
297
+ }
298
+ let enqueued = 0;
299
+ for (const [conversationId, admittedAt] of this.#lastAdmission) {
300
+ if ((this.#liveInputs.get(conversationId) ?? 0) > 0)
301
+ continue;
302
+ if ((this.#idleFiredAt.get(conversationId) ?? -Infinity) >= admittedAt) {
303
+ continue;
304
+ }
305
+ const idleMs = now - admittedAt;
306
+ if (idleMs < this.#idleThresholdMs)
307
+ continue;
308
+ this.#idleFiredAt.set(conversationId, now);
309
+ this.#enqueue({
310
+ kind: "lifecycle",
311
+ event: { type: "conversationIdle", conversationId, idleMs },
312
+ });
313
+ enqueued += 1;
314
+ }
315
+ return enqueued;
316
+ }
317
+ /**
318
+ * Emit one `userDeleted` directly (core hardening W1 Task 4): the
319
+ * DELETE /v1/users route's final step, AFTER the ownership transfer,
320
+ * token revocation, and record removal have landed. Delivered exactly
321
+ * like `harnessShutdown` — inline to every registered extension's
322
+ * handler in install order, each deadline-bounded, never queued —
323
+ * because the directory is not durable conversation state (there is
324
+ * no commit to derive from). The api is the conversation-less shape:
325
+ * `entry`/`entries` resolve empty, `document` serves any conversation.
326
+ */
327
+ async emitUserDeleted(payload, context) {
328
+ if (this.#closed)
329
+ return;
330
+ const api = this.#apiFor(undefined);
331
+ for (const [name, handlers] of this.#extensions) {
332
+ const handler = handlers.userDeleted;
333
+ if (handler === undefined)
334
+ continue;
335
+ await this.#bounded(`extension ${name} userDeleted`, () => handler(payload, api, context));
336
+ }
337
+ }
338
+ /**
339
+ * The wedge gauge's source (W2 Task 8, spec §6.9): the AGE in
340
+ * milliseconds of the oldest still-live input run — `now` minus the
341
+ * oldest per-conversation admission instant among conversations with
342
+ * live inputs. `undefined` when no run is live. In-process state (the
343
+ * admission clock): a run admitted before a restart is invisible to
344
+ * this until it settles — the spec's accepted posture for a
345
+ * wedge-while-up alarm (durable timestamps arrive with the §13 ask).
346
+ */
347
+ oldestLiveAdmissionMs(now = Date.now()) {
348
+ let oldest;
349
+ for (const [conversationId, admittedAt] of this.#lastAdmission) {
350
+ if ((this.#liveInputs.get(conversationId) ?? 0) <= 0)
351
+ continue;
352
+ if (oldest === undefined || admittedAt < oldest)
353
+ oldest = admittedAt;
354
+ }
355
+ return oldest === undefined ? undefined : Math.max(0, now - oldest);
356
+ }
357
+ /**
358
+ * The ordered shutdown sequence (Task 4; src/index.ts runs it between
359
+ * `app.close()` and `harness.close()`):
360
+ *
361
+ * 1. stop the idle sweep (no new events from here on);
362
+ * 2. stop idle workers FIRST (Task 5, spec §3): pending wakes dropped,
363
+ * in-flight runs awaited up to 30s then abandoned — a curator is not
364
+ * competing with the flush handlers for the window;
365
+ * 3. drain pending settles — a run that settled just before shutdown
366
+ * delivers before the shutdown flush (the ordering constraint);
367
+ * 4. deliver `harnessShutdown` to every extension in install order,
368
+ * each deadline-bounded. Handlers may commit — admission is still
369
+ * open in this window (the seal is `Harness.close`, the caller's
370
+ * next step), which is exactly the flush window the spec promises;
371
+ * 5. close the bus (idempotent; boot's `subscribeClose` wiring makes a
372
+ * later `harness.close` a no-op here).
373
+ */
374
+ async shutdownSequence(context) {
375
+ if (this.#closed)
376
+ return;
377
+ if (this.#sweep !== undefined) {
378
+ clearInterval(this.#sweep);
379
+ this.#sweep = undefined;
380
+ }
381
+ await this.#stopWorkers();
382
+ await this.drain(this.#hookDeadlineMs, context);
383
+ // harnessShutdown carries no conversation: the conversation-scoped
384
+ // reads resolve empty and `document` serves any conversation.
385
+ const api = this.#apiFor(undefined);
386
+ for (const [name, handlers] of this.#extensions) {
387
+ const handler = handlers.harnessShutdown;
388
+ if (handler === undefined)
389
+ continue;
390
+ await this.#bounded(`extension ${name} harnessShutdown`, () => handler(api, context));
391
+ }
392
+ this.close();
393
+ }
394
+ /**
395
+ * Drain pending events through the handlers, each deadline-bounded.
396
+ * Concurrent calls share ONE loop (the single side-lane worker), and a
397
+ * call resolves as soon as EITHER the shared loop empties the queue OR
398
+ * the call's own deadline fires — the deadline only bounds the WAIT, it
399
+ * does not reserve a share of the loop: when a loop is already running,
400
+ * its completion wins the race for every waiter, so a late caller with
401
+ * a tiny deadline can resolve AFTER that deadline (it rode the running
402
+ * loop past it). `context` is the drain's caller context, forwarded to
403
+ * the extension handlers the loop dispatches; the loop keeps the
404
+ * context of the drain that started it.
405
+ */
406
+ drain(deadlineMs, context) {
407
+ if (this.#closed)
408
+ return Promise.resolve();
409
+ if (this.#loop === undefined) {
410
+ // The clearing hook runs on a MICROTASK after the assignment
411
+ // below, even when the pump empties a ready queue without ever
412
+ // awaiting. Clearing inside the pump instead would race this
413
+ // assignment (an empty pump completes synchronously, its finally
414
+ // would clear `#loop` BEFORE the assignment stores the settled
415
+ // promise) and wedge every later drain on an already-settled loop.
416
+ const run = this.#pump(context).finally(() => {
417
+ if (this.#loop === run)
418
+ this.#loop = undefined;
419
+ });
420
+ this.#loop = run;
421
+ }
422
+ const loop = this.#loop;
423
+ let timer;
424
+ const deadline = new Promise((resolve) => {
425
+ timer = setTimeout(resolve, Math.max(0, deadlineMs));
426
+ });
427
+ return Promise.race([loop, deadline]).finally(() => {
428
+ if (timer !== undefined)
429
+ clearTimeout(timer);
430
+ });
431
+ }
432
+ /** Stop everything: unsubscribe, empty the queue, stop the sweep + workers, and let a running loop exit. */
433
+ close() {
434
+ if (this.#closed)
435
+ return;
436
+ this.#closed = true;
437
+ this.#unsubscribe();
438
+ this.#queue.length = 0;
439
+ if (this.#sweep !== undefined) {
440
+ clearInterval(this.#sweep);
441
+ this.#sweep = undefined;
442
+ }
443
+ // Workers stop too (a failed-session teardown must not leak their
444
+ // runs); in-flight runs are abandoned host-side — close is sync.
445
+ for (const state of this.#workers) {
446
+ state.stopped = true;
447
+ state.pendingWake = undefined;
448
+ }
449
+ // A running pump observes `#closed` at its next iteration and exits;
450
+ // its in-flight handler call is abandoned at the hook deadline at
451
+ // the latest, so pending drains always resolve.
452
+ }
453
+ /**
454
+ * The /v1/meta worker projection (memory-seam Task 5): extension name →
455
+ * per-worker `{name, lastRunAt?, lastError?}` (a snapshot per call; the
456
+ * server re-derives per request). Extensions without workers have no
457
+ * entry.
458
+ */
459
+ workerStatuses() {
460
+ const statuses = new Map();
461
+ for (const state of this.#workers) {
462
+ const list = statuses.get(state.extension) ?? [];
463
+ list.push({
464
+ name: state.worker.name,
465
+ ...(state.lastRunAt === undefined
466
+ ? {}
467
+ : { lastRunAt: state.lastRunAt }),
468
+ ...(state.lastError === undefined
469
+ ? {}
470
+ : { lastError: state.lastError }),
471
+ });
472
+ statuses.set(state.extension, list);
473
+ }
474
+ return statuses;
475
+ }
476
+ /**
477
+ * Wake every worker whose `wakeOn` names this lifecycle event (memory-
478
+ * seam Task 5; taps the dispatch fan-out — detection is the listener's).
479
+ * A wake on an idle worker starts a run at once; a wake during an
480
+ * in-flight run is QUEUED + COALESCED (one pending slot, last wins) and
481
+ * delivered as the next run's `wake`.
482
+ */
483
+ #wakeWorkers(event, context) {
484
+ for (const state of this.#workers) {
485
+ if (state.stopped)
486
+ continue;
487
+ if (!state.worker.wakeOn?.includes(event.type))
488
+ continue;
489
+ const wake = {
490
+ event: event.type,
491
+ conversationId: this.#lifecycleConversation(event),
492
+ };
493
+ if (state.running) {
494
+ state.pendingWake = wake;
495
+ }
496
+ else {
497
+ this.#startRun(state, wake, context);
498
+ }
499
+ }
500
+ }
501
+ /**
502
+ * Start one worker run. One run at a time per worker; the run is bounded
503
+ * by the worker deadline — a timeout ABANDONS the run (logged; the
504
+ * zombie promise keeps executing, late writes unguarded — accepted,
505
+ * spec §3) and the next tick still runs. A coalesced pending wake
506
+ * triggers one follow-up run immediately.
507
+ */
508
+ #startRun(state, wake, context) {
509
+ if (state.stopped || state.running)
510
+ return;
511
+ state.running = true;
512
+ state.lastRunAt = Date.now();
513
+ const label = `extension ${state.extension} worker ${state.worker.name}`;
514
+ let settled = false;
515
+ let timer;
516
+ // `inflight` resolves when the HOST considers the run over (complete),
517
+ // not when the worker's promise settles — a zombie would otherwise
518
+ // wedge the shutdown await (the 30s cap is the outer bound, but a
519
+ // deadline-abandon must release it at once).
520
+ let hostDone = () => { };
521
+ state.inflight = new Promise((resolve) => {
522
+ hostDone = resolve;
523
+ });
524
+ const complete = (error) => {
525
+ if (settled)
526
+ return;
527
+ settled = true;
528
+ if (timer !== undefined)
529
+ clearTimeout(timer);
530
+ state.running = false;
531
+ state.inflight = undefined;
532
+ hostDone();
533
+ if (error === undefined) {
534
+ state.lastError = undefined;
535
+ }
536
+ else {
537
+ state.lastError =
538
+ error instanceof Error ? error.message : String(error);
539
+ console.error("event-bus: %s failed: %s", label, state.lastError);
540
+ }
541
+ if (!state.stopped && state.pendingWake !== undefined) {
542
+ const nextWake = state.pendingWake;
543
+ state.pendingWake = undefined;
544
+ this.#startRun(state, nextWake, context);
545
+ }
546
+ };
547
+ timer = setTimeout(() => {
548
+ const message = `exceeded the ${state.deadlineMs}ms worker deadline — abandoned`;
549
+ // Logged ONCE by complete (as the run's failure, which also stamps
550
+ // lastError); a separate log here would double-report.
551
+ complete(new Error(message));
552
+ }, Math.max(0, state.deadlineMs));
553
+ timer.unref?.();
554
+ let result;
555
+ try {
556
+ result = state.worker.run(context, wake);
557
+ }
558
+ catch (error) {
559
+ complete(error);
560
+ return;
561
+ }
562
+ Promise.resolve(result).then(() => complete(undefined), (error) => complete(error));
563
+ }
564
+ /**
565
+ * Stop every worker (spec §3 shutdown ordering): pending wakes dropped,
566
+ * in-flight runs awaited up to 30s then abandoned. Runs before the
567
+ * harnessShutdown delivery — a curator is not competing with the flush
568
+ * handlers for the same window.
569
+ */
570
+ async #stopWorkers() {
571
+ for (const state of this.#workers) {
572
+ state.stopped = true;
573
+ state.pendingWake = undefined;
574
+ }
575
+ const inflight = this.#workers
576
+ .map((state) => state.inflight)
577
+ .filter((one) => one !== undefined);
578
+ if (inflight.length === 0)
579
+ return;
580
+ let timer;
581
+ const cap = new Promise((resolve) => {
582
+ timer = setTimeout(resolve, WORKER_SHUTDOWN_AWAIT_MS);
583
+ });
584
+ await Promise.race([Promise.all(inflight).then(() => undefined), cap]);
585
+ if (timer !== undefined)
586
+ clearTimeout(timer);
587
+ for (const state of this.#workers) {
588
+ if (state.running) {
589
+ console.error("event-bus: extension %s worker %s still running at shutdown — abandoned", state.extension, state.worker.name);
590
+ state.running = false;
591
+ }
592
+ }
593
+ }
594
+ /**
595
+ * The commit listener: synchronous filter + enqueue ONLY (the upstream
596
+ * contract). Never throws — an observation failure is logged and the
597
+ * publication is skipped, never reported into the Session.
598
+ */
599
+ observe(publication) {
600
+ try {
601
+ const lifecycle = [];
602
+ const forkParents = new Map();
603
+ for (const change of publication.changes) {
604
+ if (change.type !== "conversation")
605
+ continue;
606
+ const record = change.value;
607
+ lifecycle.push({
608
+ type: "conversationCreated",
609
+ conversationId: record.id,
610
+ });
611
+ // W3 Task 4: the host event mirrors the creation detection.
612
+ this.#emitHost({
613
+ type: "conversation.created",
614
+ conversationId: record.id,
615
+ });
616
+ if (record.parent !== undefined) {
617
+ forkParents.set(record.id, record.parent.conversationId);
618
+ lifecycle.push({
619
+ type: "conversationForked",
620
+ childId: record.id,
621
+ parentId: record.parent.conversationId,
622
+ at: record.parent.at,
623
+ });
624
+ }
625
+ }
626
+ for (const change of publication.changes) {
627
+ if (change.type !== "document" && change.type !== "document.copy") {
628
+ continue;
629
+ }
630
+ const kind = change.record.kind;
631
+ if (kind !== SessionMetaDoc.definition.kind) {
632
+ // W3 Task 4: the interaction + ambient seam doc families feed
633
+ // their host events here (ordinary document changes only — a
634
+ // fork's document.copy carries no status transition).
635
+ if (change.type === "document" &&
636
+ kind === InteractionDoc.definition.kind &&
637
+ change.conversationId !== undefined &&
638
+ change.value !== null) {
639
+ const record = change.value;
640
+ if (typeof record.callId === "string" &&
641
+ typeof record.kind === "string" &&
642
+ typeof record.status === "string") {
643
+ this.#emitHost({
644
+ type: INTERACTION_TERMINAL.has(record.status)
645
+ ? "interaction.settled"
646
+ : "interaction.pending",
647
+ conversationId: change.conversationId,
648
+ callId: record.callId,
649
+ kind: record.kind,
650
+ });
651
+ }
652
+ }
653
+ else if (change.type === "document" &&
654
+ kind === AmbientDoc.definition.kind &&
655
+ change.conversationId !== undefined &&
656
+ typeof change.record.key === "string") {
657
+ this.#emitHost({
658
+ type: "ambient.changed",
659
+ conversationId: change.conversationId,
660
+ key: change.record.key,
661
+ });
662
+ }
663
+ continue;
664
+ }
665
+ const conversationId = change.conversationId;
666
+ if (conversationId === undefined)
667
+ continue;
668
+ if (change.type === "document.copy") {
669
+ // The fork initializes the child from the parent's current
670
+ // state: seed the child's baseline, never fire.
671
+ const parent = forkParents.get(conversationId);
672
+ const inherited = parent === undefined
673
+ ? false
674
+ : (this.#archivedBasis.get(parent) ?? false);
675
+ this.#archivedBasis.set(conversationId, inherited);
676
+ // W3 Task 4: seed the child's summary basis from the parent's
677
+ // (fork: "current" — the child starts from the parent's
678
+ // current doc; owner is NOT inherited — the auth hook stamps
679
+ // the forker's own owner in a separate change that diffs
680
+ // against this basis).
681
+ const parentBasis = parent === undefined ? undefined : this.#metaBasis.get(parent);
682
+ const childBasis = {
683
+ archived: inherited,
684
+ pinned: parentBasis?.pinned ?? false,
685
+ };
686
+ if (parentBasis?.name !== undefined)
687
+ childBasis.name = parentBasis.name;
688
+ if (parentBasis?.autoName !== undefined)
689
+ childBasis.autoName = parentBasis.autoName;
690
+ if (parentBasis?.lastActivityAt !== undefined)
691
+ childBasis.lastActivityAt = parentBasis.lastActivityAt;
692
+ if (parentBasis?.sharing !== undefined)
693
+ childBasis.sharing = parentBasis.sharing;
694
+ this.#metaBasis.set(conversationId, childBasis);
695
+ continue;
696
+ }
697
+ if (change.value === null) {
698
+ this.#archivedBasis.delete(conversationId);
699
+ this.#metaBasis.delete(conversationId);
700
+ continue;
701
+ }
702
+ const archived = change.value.archived === true;
703
+ // W3 Task 4: the summary basis — seed the ARCHIVE flip baseline on
704
+ // creation, but the generalized diff below still runs against the
705
+ // PRIOR basis (default for a first patch): the creation patch
706
+ // materializes the doc WITH its fields (e.g. a create-time name),
707
+ // and those fields are real `conversation.changed` news.
708
+ if (change.record.createdAt === publication.seq) {
709
+ this.#archivedBasis.set(conversationId, archived);
710
+ const basis = this.#metaBasis.get(conversationId) ?? {
711
+ archived: false,
712
+ pinned: false,
713
+ };
714
+ const next = metaBasisOf(change.value);
715
+ const moved = {};
716
+ if (next.name !== basis.name) {
717
+ moved.name = next.name === undefined ? null : next.name;
718
+ }
719
+ if (next.autoName !== basis.autoName) {
720
+ moved.autoName = next.autoName === undefined ? null : next.autoName;
721
+ }
722
+ // archived/pinned too (fix round 1): a conversation
723
+ // materialized already-pinned/-archived is real changed news.
724
+ if (next.archived !== basis.archived)
725
+ moved.archived = next.archived;
726
+ if (next.pinned !== basis.pinned)
727
+ moved.pinned = next.pinned;
728
+ if (next.lastActivityAt !== basis.lastActivityAt) {
729
+ moved.lastActivityAt =
730
+ next.lastActivityAt === undefined ? null : next.lastActivityAt;
731
+ }
732
+ if (next.owner !== basis.owner) {
733
+ moved.owner = next.owner === undefined ? null : next.owner;
734
+ }
735
+ if (next.sharing !== basis.sharing) {
736
+ moved.sharing =
737
+ next.sharing === undefined ? null : JSON.parse(next.sharing);
738
+ }
739
+ this.#metaBasis.set(conversationId, next);
740
+ if (Object.keys(moved).length > 0) {
741
+ this.#emitHost({
742
+ type: "conversation.changed",
743
+ conversationId,
744
+ fields: moved,
745
+ });
746
+ }
747
+ continue;
748
+ }
749
+ const prior = this.#archivedBasis.get(conversationId) ?? false;
750
+ if (prior !== archived) {
751
+ this.#archivedBasis.set(conversationId, archived);
752
+ lifecycle.push({
753
+ type: "conversationArchived",
754
+ conversationId,
755
+ archived,
756
+ });
757
+ }
758
+ // W3 Task 4: the generalized summary diff — ANY tracked field
759
+ // that moved against the baseline emits one `conversation.changed`
760
+ // carrying exactly the moved fields' new values. The default
761
+ // basis (absent doc / predating this process) compares against
762
+ // the materialization defaults (archived/pinned false), mirroring
763
+ // the archive-flip rule above.
764
+ const basis = this.#metaBasis.get(conversationId) ?? {
765
+ archived: false,
766
+ pinned: false,
767
+ };
768
+ const next = metaBasisOf(change.value);
769
+ // Built through a mutable mirror (the wire fields are readonly);
770
+ // cleared keys ride as null (explicit-clear semantics).
771
+ const moved = {};
772
+ if (next.name !== basis.name) {
773
+ moved.name = next.name === undefined ? null : next.name;
774
+ }
775
+ if (next.autoName !== basis.autoName) {
776
+ moved.autoName = next.autoName === undefined ? null : next.autoName;
777
+ }
778
+ if (next.archived !== basis.archived)
779
+ moved.archived = next.archived;
780
+ if (next.pinned !== basis.pinned)
781
+ moved.pinned = next.pinned;
782
+ if (next.lastActivityAt !== basis.lastActivityAt) {
783
+ moved.lastActivityAt =
784
+ next.lastActivityAt === undefined ? null : next.lastActivityAt;
785
+ }
786
+ if (next.owner !== basis.owner) {
787
+ moved.owner = next.owner === undefined ? null : next.owner;
788
+ }
789
+ if (next.sharing !== basis.sharing) {
790
+ moved.sharing =
791
+ next.sharing === undefined ? null : JSON.parse(next.sharing);
792
+ }
793
+ this.#metaBasis.set(conversationId, next);
794
+ const movedKeys = Object.keys(moved);
795
+ if (movedKeys.length > 0) {
796
+ const fields = {};
797
+ for (const key of movedKeys) {
798
+ // Keyed assignment through the mutable mirror above; the
799
+ // wire type's readonly is the CONSUMER-side contract.
800
+ fields[key] = moved[key];
801
+ }
802
+ this.#emitHost({
803
+ type: "conversation.changed",
804
+ conversationId,
805
+ fields,
806
+ });
807
+ }
808
+ }
809
+ const groups = new Map();
810
+ // Jobs Task 3 (§8.1): a `task` table write whose status is TERMINAL
811
+ // reduces to a host-level `taskSettled` — the jobs observer's input
812
+ // for settling `task`-action run records. Emitted from the SAME
813
+ // synchronous filter (the no-blocking-work contract), never queued:
814
+ // it is host machinery (withheld from the SSE wire), and its
815
+ // consumer runs on the drain loop.
816
+ for (const change of publication.changes) {
817
+ if (change.type !== "task")
818
+ continue;
819
+ const task = change.value;
820
+ // `completing` is decided-but-not-finalized; the jobs observer
821
+ // waits for the terminal receipt — emit only on `terminal`.
822
+ if (task.state.status !== "terminal")
823
+ continue;
824
+ const outcome = task.state.outcome;
825
+ // The error text rides whichever field the outcome shape
826
+ // carries: `failed` has `error` (a TaskOutcomeError), `aborted`
827
+ // has `reason` (§8.1: an aborted task-action run settles
828
+ // failed WITH the text, never empty).
829
+ const errorText = outcome.status === "completed"
830
+ ? undefined
831
+ : "error" in outcome
832
+ ? outcome.error?.message
833
+ : "reason" in outcome
834
+ ? outcome.reason
835
+ : undefined;
836
+ this.#emitHost({
837
+ type: "taskSettled",
838
+ // SAFETY: TaskId is the branded number the wire's plain `number`
839
+ // field carries; the observer compares by === across both forms.
840
+ taskId: task.id,
841
+ status: outcome.status,
842
+ ...(errorText === undefined ? {} : { error: errorText }),
843
+ });
844
+ }
845
+ for (const change of publication.changes) {
846
+ if (change.type !== "submission")
847
+ continue;
848
+ const value = change.value;
849
+ if (value.type !== "input")
850
+ continue;
851
+ // Idle bookkeeping (Task 4): a PLACED input both stamps the
852
+ // admission clock and marks the run live; a terminal input marks
853
+ // the run end. Write-type submissions never touch either — a
854
+ // passive write must not reset the idle clock (spec §2). Queued
855
+ // inputs are not admissions (they have not placed an entry).
856
+ if (value.status === "placed") {
857
+ this.#lastAdmission.set(value.conversationId, Date.now());
858
+ this.#liveInputs.set(value.conversationId, (this.#liveInputs.get(value.conversationId) ?? 0) + 1);
859
+ }
860
+ else if (value.status === "done" ||
861
+ value.status === "unanswered") {
862
+ // Gate on the record's ENTRY: a placed-then-settled input keeps
863
+ // its entry (upstream `applySubmissionChange`: "an unanswered
864
+ // input keeps its entry"), while a queued-never-placed withdrawal
865
+ // (abortSubmission on a queued id, Conversation.abort's withdrawal
866
+ // pass) settles with `entry === undefined` — it was never counted
867
+ // live, and decrementing for it would zero the run's count
868
+ // mid-run and let the sweep fire idle (Review Focus 6). The
869
+ // settle-group emission below is NOT gated: a withdrawal still
870
+ // emits runSettled (Task 3's position-preserving contract).
871
+ if (value.entry !== undefined) {
872
+ // Skip when the key is absent: a conversation archived
873
+ // mid-run has its idle state pruned (see sweepIdle), and a
874
+ // settle arriving after the prune must NOT re-create the
875
+ // entry with a floored 0 — that would re-admit the archived
876
+ // conversation to the sweep (and a still-running second
877
+ // input would then fire idle MID-RUN).
878
+ const live = this.#liveInputs.get(value.conversationId);
879
+ if (live !== undefined) {
880
+ this.#liveInputs.set(value.conversationId, Math.max(0, live - 1));
881
+ }
882
+ }
883
+ }
884
+ // W3 Task 4: host events for run admission and settlement. The
885
+ // status arms map verbatim: placed → run.admitted, done →
886
+ // run.settled answered, unanswered → run.settled unanswered with
887
+ // the record's reason.
888
+ if (value.status === "placed") {
889
+ this.#emitHost({
890
+ type: "run.admitted",
891
+ conversationId: value.conversationId,
892
+ submissionId: value.id,
893
+ });
894
+ }
895
+ else if (value.status === "done") {
896
+ this.#emitHost({
897
+ type: "run.settled",
898
+ conversationId: value.conversationId,
899
+ outcome: "answered",
900
+ });
901
+ }
902
+ else if (value.status === "unanswered") {
903
+ this.#emitHost({
904
+ type: "run.settled",
905
+ conversationId: value.conversationId,
906
+ outcome: "unanswered",
907
+ ...(value.reason === undefined ? {} : { reason: value.reason }),
908
+ });
909
+ }
910
+ if (value.status !== "done" && value.status !== "unanswered")
911
+ continue;
912
+ const list = groups.get(value.conversationId) ?? [];
913
+ list.push({
914
+ id: value.id,
915
+ status: value.status,
916
+ entryId: value.entry,
917
+ answerEntryId: value.answer,
918
+ reason: value.reason,
919
+ });
920
+ groups.set(value.conversationId, list);
921
+ }
922
+ const ordered = [...groups.entries()]
923
+ .map(([conversationId, settled]) => ({
924
+ conversationId,
925
+ settled: [...settled].sort((a, b) => a.id - b.id),
926
+ }))
927
+ .sort((a, b) => a.settled[0].id - b.settled[0].id);
928
+ // Settles enqueue first: within one publication, a run's settle
929
+ // precedes any archive flip of the same conversation.
930
+ for (const event of ordered)
931
+ this.#enqueue({ kind: "settled", event });
932
+ for (const event of lifecycle)
933
+ this.#enqueue({ kind: "lifecycle", event });
934
+ }
935
+ catch (error) {
936
+ console.error("event-bus: commit observation failed:", error);
937
+ }
938
+ }
939
+ /** Enqueue, dropping the OLDEST event (and logging) on overflow. */
940
+ #enqueue(event) {
941
+ if (this.#queue.length >= this.#queueMax) {
942
+ const dropped = this.#queue.shift();
943
+ if (dropped !== undefined) {
944
+ console.error("event-bus: queue overflow (queueMax %d) — dropped the oldest %s event", this.#queueMax, dropped.kind);
945
+ }
946
+ }
947
+ this.#queue.push(event);
948
+ }
949
+ /** The single side-lane worker: pop in publication order until empty or closed. */
950
+ async #pump(context) {
951
+ for (;;) {
952
+ if (this.#closed)
953
+ return;
954
+ const next = this.#queue.shift();
955
+ if (next === undefined)
956
+ return;
957
+ await this.#dispatch(next, context);
958
+ }
959
+ }
960
+ /** Run one event's handlers sequentially in install order. */
961
+ async #dispatch(item, context) {
962
+ if (item.kind === "settled") {
963
+ for (const handler of this.#settleHandlers) {
964
+ const handlerRef = handler;
965
+ await this.#bounded("settled", () => handlerRef(item.event));
966
+ }
967
+ await this.#dispatchRunSettled(item.event, context);
968
+ }
969
+ else {
970
+ for (const handler of this.#lifecycleHandlers) {
971
+ const handlerRef = handler;
972
+ await this.#bounded("lifecycle", () => handlerRef(item.event));
973
+ }
974
+ await this.#dispatchLifecycle(item.event, context);
975
+ }
976
+ }
977
+ /**
978
+ * Fan one lifecycle event out to every registered extension's matching
979
+ * handler (the handler NAME discriminates — the extension-facing payload
980
+ * carries no `type`). The api is scoped to the event's conversation
981
+ * (the fork child for `conversationForked`).
982
+ */
983
+ async #dispatchLifecycle(event, context) {
984
+ const type = event.type;
985
+ // Task 5: wakes tap the lifecycle fan-out — matched and queued HERE,
986
+ // regardless of whether any extension registered a handler for it.
987
+ this.#wakeWorkers(event, context);
988
+ const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers[type] !== undefined);
989
+ if (registered.length === 0)
990
+ return;
991
+ const payload = this.#lifecyclePayload(event);
992
+ const api = this.#apiFor(this.#lifecycleConversation(event));
993
+ for (const [name, handlers] of registered) {
994
+ const handler = handlers[type];
995
+ // One localized cast: the payload union cannot be correlated to the
996
+ // dynamically-keyed handler at the type level, but both sides come
997
+ // from the same `ExtensionLifecyclePayload` map.
998
+ await this.#bounded(`extension ${name} ${type}`, () => handler(payload, api, context));
999
+ }
1000
+ }
1001
+ /** The extension-facing payload of one lifecycle event (the `type` discriminator stripped). */
1002
+ #lifecyclePayload(event) {
1003
+ switch (event.type) {
1004
+ case "conversationCreated":
1005
+ return { conversationId: event.conversationId };
1006
+ case "conversationForked":
1007
+ return { childId: event.childId, parentId: event.parentId, at: event.at };
1008
+ case "conversationArchived":
1009
+ return { conversationId: event.conversationId, archived: event.archived };
1010
+ case "conversationIdle":
1011
+ return { conversationId: event.conversationId, idleMs: event.idleMs };
1012
+ }
1013
+ }
1014
+ /** The conversation a lifecycle event's api scopes to (the fork child on forks). */
1015
+ #lifecycleConversation(event) {
1016
+ return event.type === "conversationForked" ? event.childId : event.conversationId;
1017
+ }
1018
+ /**
1019
+ * Fan one settled group out to every registered extension's
1020
+ * `runSettled`, as a resolved `RunSettledEvent`. The raw `SettleGroup`
1021
+ * handlers above have already run; entry reads happen HERE, on the
1022
+ * drain loop — never in the commit listener (the upstream contract).
1023
+ */
1024
+ async #dispatchRunSettled(group, context) {
1025
+ const registered = [...this.#extensions.entries()].filter(([, handlers]) => handlers.runSettled !== undefined);
1026
+ if (registered.length === 0)
1027
+ return;
1028
+ let event;
1029
+ try {
1030
+ event = await this.#resolveRunSettled(group, context);
1031
+ }
1032
+ catch (error) {
1033
+ console.error("event-bus: runSettled resolution failed:", error);
1034
+ return;
1035
+ }
1036
+ const api = this.#apiFor(group.conversationId);
1037
+ for (const [name, handlers] of registered) {
1038
+ const runSettled = handlers.runSettled;
1039
+ await this.#bounded(`extension ${name} runSettled`, () => runSettled(event, api, context));
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Resolve one group into the extension-facing event: per-input text
1044
+ * (any failure → `""` at that position) and the run outcome. A group is
1045
+ * one run's inputs settling in one commit, so the outcome is the run's:
1046
+ * answered only when EVERY input answered (they share the run's final
1047
+ * answer — the last record's), otherwise unanswered with the first
1048
+ * unanswered record's reason. An answer entry that cannot be read
1049
+ * (immutable once committed; defensive) downgrades to unanswered.
1050
+ */
1051
+ async #resolveRunSettled(group, context) {
1052
+ const inputs = [];
1053
+ const inputTexts = [];
1054
+ for (const one of group.settled) {
1055
+ inputs.push(one.id);
1056
+ inputTexts.push(one.entryId === undefined
1057
+ ? ""
1058
+ : await this.#entryText(group.conversationId, one.entryId, context));
1059
+ }
1060
+ const all = group.settled;
1061
+ const everyAnswered = all.length > 0 && all.every((one) => one.status === "done");
1062
+ if (everyAnswered) {
1063
+ const last = all[all.length - 1];
1064
+ const answerEntryId = last.answerEntryId;
1065
+ const entry = await this.#readEntry(group.conversationId, answerEntryId, context);
1066
+ const message = entry !== undefined && AssistantEntry.is(entry)
1067
+ ? entry.model?.[0]
1068
+ : undefined;
1069
+ if (message !== undefined && message.role === "assistant") {
1070
+ return {
1071
+ conversationId: group.conversationId,
1072
+ inputs,
1073
+ inputTexts,
1074
+ outcome: { status: "answered", answerEntryId, answer: message },
1075
+ };
1076
+ }
1077
+ console.error("event-bus: answer entry %s could not be resolved — outcome downgraded", String(answerEntryId));
1078
+ }
1079
+ const firstUnanswered = all.find((one) => one.status === "unanswered");
1080
+ return {
1081
+ conversationId: group.conversationId,
1082
+ inputs,
1083
+ inputTexts,
1084
+ outcome: {
1085
+ status: "unanswered",
1086
+ reason: firstUnanswered?.reason ?? "unanswered",
1087
+ },
1088
+ };
1089
+ }
1090
+ /** One visible entry by exact id (`undefined` on absence or read failure). */
1091
+ async #readEntry(conversationId, id, context) {
1092
+ try {
1093
+ const conversation = await this.#harness.conversation(conversationId, context);
1094
+ if (conversation === undefined)
1095
+ return undefined;
1096
+ const page = await conversation.entries({ minEntryId: id, maxEntryId: id }, 1, undefined, context);
1097
+ return page.items[0];
1098
+ }
1099
+ catch (error) {
1100
+ console.error("event-bus: entry read failed:", error);
1101
+ return undefined;
1102
+ }
1103
+ }
1104
+ /** The input text of one entry: a `pi.user` (or an expanded input's
1105
+ * `sigrun.user.expanded`) entry's `model[0]` text blocks joined — for
1106
+ * an expanded input that is the ADMITTED text (what the model
1107
+ * received), exactly what `runSettled` hands its handlers. */
1108
+ async #entryText(conversationId, id, context) {
1109
+ const entry = await this.#readEntry(conversationId, id, context);
1110
+ if (entry === undefined)
1111
+ return "";
1112
+ // An expanded input REPLACES the `pi.user` kind (Task 2c's
1113
+ // `entry: {kind: …}` placement) — both kinds are input entries.
1114
+ if (!UserEntry.is(entry) && entry.kind !== EXPANDED_ENTRY_KIND) {
1115
+ return "";
1116
+ }
1117
+ const message = entry.model?.[0];
1118
+ if (message === undefined || message.role !== "user")
1119
+ return "";
1120
+ if (typeof message.content === "string")
1121
+ return message.content;
1122
+ return message.content
1123
+ .filter((block) => block.type === "text")
1124
+ .map((block) => block.text)
1125
+ .join("");
1126
+ }
1127
+ /**
1128
+ * The narrowed read surface handed to event handlers. `conversationId`
1129
+ * `undefined` (only `harnessShutdown`) scopes the conversation-bound
1130
+ * reads to no conversation: `entry` resolves `undefined`, `entries`
1131
+ * resolves empty, and `document` — which names its conversation per call
1132
+ * — keeps serving any conversation.
1133
+ */
1134
+ #apiFor(conversationId) {
1135
+ const harness = this.#harness;
1136
+ if (conversationId === undefined) {
1137
+ return {
1138
+ entry: async () => undefined,
1139
+ entries: async () => ({ items: [] }),
1140
+ document: ((token, id, context) => harness.snapshot(token, id, context)),
1141
+ };
1142
+ }
1143
+ return {
1144
+ entry: (id, context) => this.#readEntry(conversationId, id, context),
1145
+ entries: (query, limit, cursor, context) => this.#entriesOf(conversationId, query, limit, cursor, context),
1146
+ // The snapshot overloads are generic per call; the narrowed shape
1147
+ // carries one conversation-doc read, forwarded verbatim.
1148
+ document: ((token, id, context) => harness.snapshot(token, id, context)),
1149
+ };
1150
+ }
1151
+ /** `Conversation.entries` over the settled conversation (read failures resolve empty). */
1152
+ async #entriesOf(conversationId, query, limit, cursor, context) {
1153
+ try {
1154
+ const conversation = await this.#harness.conversation(conversationId, context);
1155
+ if (conversation === undefined)
1156
+ return { items: [] };
1157
+ return await conversation.entries(query, limit, cursor, context);
1158
+ }
1159
+ catch (error) {
1160
+ console.error("event-bus: entries read failed:", error);
1161
+ return { items: [] };
1162
+ }
1163
+ }
1164
+ /**
1165
+ * One handler call: resolved by the call or the hook deadline,
1166
+ * whichever first. An abandoned handler's eventual rejection is
1167
+ * logged here (`failed` below runs before the no-op `finish`) — it
1168
+ * already lost the race, and nobody else can observe it.
1169
+ */
1170
+ #bounded(label, call) {
1171
+ return new Promise((resolve) => {
1172
+ let settled = false;
1173
+ let timer;
1174
+ const finish = () => {
1175
+ if (settled)
1176
+ return;
1177
+ settled = true;
1178
+ if (timer !== undefined)
1179
+ clearTimeout(timer);
1180
+ resolve();
1181
+ };
1182
+ timer = setTimeout(() => {
1183
+ console.error("event-bus: %s handler exceeded the %dms hook deadline — abandoned", label, this.#hookDeadlineMs);
1184
+ finish();
1185
+ }, Math.max(0, this.#hookDeadlineMs));
1186
+ // A handler may throw synchronously; that is the same failure as a
1187
+ // rejected promise and must not escape into the drain loop.
1188
+ const failed = (error) => {
1189
+ console.error("event-bus: %s handler failed:", label, error);
1190
+ finish();
1191
+ };
1192
+ let result;
1193
+ try {
1194
+ result = call();
1195
+ }
1196
+ catch (error) {
1197
+ failed(error);
1198
+ return;
1199
+ }
1200
+ Promise.resolve(result).then(finish, failed);
1201
+ });
1202
+ }
1203
+ }