@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.
- package/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- 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
|
+
}
|