@intx/hub-sessions 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,663 @@
1
+ // Lookups that the sidecar wire layer issues against host state. These
2
+ // are queries (one answer per question) rather than events (broadcast
3
+ // notifications), so they live separately from the event emitter.
4
+ //
5
+ // Each lookup is a stateless DB or repo call. They are gathered into a
6
+ // single struct that the hub app passes to `createSidecarRouter` as
7
+ // `lookups`.
8
+ import { eq, and, asc, inArray, isNull } from "drizzle-orm";
9
+ import { createApprovalStore, createSignalCorrelationStore, createWorkflowRunDispatchStore, createWorkflowRunStore, } from "@intx/db";
10
+ import { agentSession, liveWorkflowRunStatuses, principal, sessionMail, sidecarAllocation, workflowRun, } from "@intx/db/schema";
11
+ import { getLogger } from "@intx/log";
12
+ import { parseRunAddress, signalName } from "@intx/types";
13
+ import { SignalDeliverFrame } from "@intx/types/sidecar";
14
+ import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy";
15
+ import { generateId } from "@intx/hub-common";
16
+ import { listAcceptedWorkflowDispatches, listConsumedWorkflowDispatches, } from "./workflow-dispatch-settlement.js";
17
+ import { readCommittedWorkflowRunLifecycle } from "./workflow-run-kind.js";
18
+ const logger = getLogger(["hub", "lookups"]);
19
+ export function createHubSessionLookups(deps) {
20
+ const { db, agentRepoStore } = deps;
21
+ const signalCorrelationStore = createSignalCorrelationStore(db);
22
+ const approvalStore = createApprovalStore(db);
23
+ const workflowRunStore = createWorkflowRunStore(db);
24
+ const workflowRunDispatchStore = createWorkflowRunDispatchStore(db);
25
+ return {
26
+ async lookupPublicKey(agentAddress) {
27
+ // Every routable address names one workflow run, whose key lives on its
28
+ // single self-anchored workflow_run row, keyed by address. Read the key
29
+ // off that row, gated on a live run (born "deployed", "running" after its
30
+ // first trigger) so a decommissioned deployment's key can no longer
31
+ // satisfy a challenge. The "deployed" arm is load-bearing: the reconnect
32
+ // ownership challenge fires in the deploy->first-trigger window, so a
33
+ // "running"-only gate would fail every such challenge closed. A missing
34
+ // row or a null publicKey (live but not yet acked) returns null so the
35
+ // reconnect challenge fails closed and the address stays unrouted rather
36
+ // than routing without ownership proof.
37
+ const row = await db
38
+ .select({ publicKey: workflowRun.publicKey })
39
+ .from(workflowRun)
40
+ .where(and(eq(workflowRun.address, agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
41
+ .limit(1)
42
+ .then((rows) => rows[0]);
43
+ return row?.publicKey ?? null;
44
+ },
45
+ async lookupDeployRef() {
46
+ // A workflow run is a supervised workflow-process pinned forever like a
47
+ // native deployment: it keeps its deploy-time definition and never
48
+ // reconciles, so no address enrolls in the reconnect deploy-ref catch-up.
49
+ return null;
50
+ },
51
+ async persistMail({ senderAddress, recipients, raw }) {
52
+ // The sender and recipients are run addresses, each backed by its
53
+ // self-anchored workflow_run; resolve each through the resolver. A mail
54
+ // record's `runId` is always null for a run -- it keys on the run's
55
+ // session instead.
56
+ const sender = await resolveRoutableAddress(db, senderAddress);
57
+ if (sender === undefined) {
58
+ throw new Error(`No active endpoint found for sender address "${senderAddress}"`);
59
+ }
60
+ if (sender.sessionId === null) {
61
+ throw new Error(`Endpoint ${sender.id} has no session for address "${senderAddress}"`);
62
+ }
63
+ const createdAt = new Date();
64
+ const senderRunId = null;
65
+ // Outbound record on the sender's session.
66
+ const outboundId = generateId("sessionMail");
67
+ const outboundRecord = {
68
+ id: outboundId,
69
+ sessionId: sender.sessionId,
70
+ runId: senderRunId,
71
+ tenantId: sender.tenantId,
72
+ direction: "outbound",
73
+ status: "delivered",
74
+ raw,
75
+ createdAt,
76
+ };
77
+ // Inbound records for each recipient that is a live endpoint.
78
+ // Recipients that are not (e.g. human user addresses) are skipped.
79
+ const recipientResults = await Promise.all(recipients.map(async (addr) => {
80
+ const endpoint = await resolveRoutableAddress(db, addr);
81
+ if (endpoint === undefined) {
82
+ return null;
83
+ }
84
+ if (endpoint.sessionId === null) {
85
+ logger.warn `Active endpoint ${endpoint.id} for "${addr}" has no session; skipping inbound record`;
86
+ return null;
87
+ }
88
+ return { addr, endpoint, sessionId: endpoint.sessionId };
89
+ }));
90
+ const recipientEndpoints = recipientResults.filter((r) => r !== null);
91
+ const inboundEntries = recipientEndpoints.map(({ addr, endpoint, sessionId }) => {
92
+ const id = generateId("sessionMail");
93
+ // A folded run is not an instance, so its mail records no runId.
94
+ const runId = null;
95
+ return {
96
+ record: {
97
+ id,
98
+ sessionId,
99
+ runId,
100
+ tenantId: endpoint.tenantId,
101
+ direction: "inbound",
102
+ status: "delivered",
103
+ raw,
104
+ createdAt,
105
+ },
106
+ result: {
107
+ id,
108
+ direction: "inbound",
109
+ runId,
110
+ address: addr,
111
+ createdAt,
112
+ },
113
+ };
114
+ });
115
+ await db
116
+ .insert(sessionMail)
117
+ .values([outboundRecord, ...inboundEntries.map((e) => e.record)]);
118
+ return [
119
+ {
120
+ id: outboundId,
121
+ direction: "outbound",
122
+ runId: senderRunId,
123
+ address: sender.address,
124
+ createdAt,
125
+ },
126
+ ...inboundEntries.map((e) => e.result),
127
+ ];
128
+ },
129
+ async registerSignalCorrelation({ correlationId, runId, anchorRunId, agentAddress, kind, approvalSnapshot, }) {
130
+ // Resolve tenancy and co-write both rows in one transaction so a resolver
131
+ // never sees a correlation without its approval or vice versa. Both
132
+ // inserts are idempotent on their dedup key (the signal_correlation
133
+ // primary key and the approval's unique correlationId), so a redelivered
134
+ // frame -- sidecar reconnect, workflow-log replay, supervisor restart
135
+ // re-emitting -- is a no-op rather than a unique-violation. `timeoutAt` is
136
+ // null: an agent-step suspend holds indefinitely (`parkOnSignal` is called
137
+ // with no timeout), so no deadline reaches this co-write.
138
+ await db.transaction(async (tx) => {
139
+ // Resolve tenancy and the run's definition from the deployment's anchor
140
+ // run -- the workflow_run whose id is the deployment id, which the
141
+ // address names. The anchor is the tenancy origin every approval needs
142
+ // (an approval has no agent_instance/agent/principal referent). The
143
+ // lookup keys off `address` (the field the wire layer's ownership gate
144
+ // authorized), not the frame's `anchorRunId`: that is the workflow-run
145
+ // repo slug the supervisor derives from the address
146
+ // (`deriveWorkflowRunRepoId`), cross-checked below against the slug
147
+ // re-derived from `agentAddress` rather than against the row id. A
148
+ // mismatch fails loud instead of silently writing an inconsistent pair.
149
+ // The FK columns take the anchor run's id (= the deployment id), which
150
+ // is what `signal_correlation.anchor_run_id` and `approval.anchor_run_id`
151
+ // reference.
152
+ //
153
+ // The resolution takes a `FOR UPDATE` row lock and runs inside the
154
+ // co-write transaction, gated on a live anchor run ("deployed" or
155
+ // "running"), so the liveness check and the inserts are atomic against a
156
+ // concurrent teardown that flips the anchor run terminal. The lock order
157
+ // is workflow_run before signal_correlation and approval; a teardown
158
+ // path must take the anchor-run lock before touching those rows to keep
159
+ // the ordering acyclic.
160
+ const anchor = await tx
161
+ .select({
162
+ id: workflowRun.id,
163
+ tenantId: workflowRun.tenantId,
164
+ definitionId: workflowRun.definitionId,
165
+ })
166
+ .from(workflowRun)
167
+ .where(and(eq(workflowRun.address, agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
168
+ .for("update")
169
+ .limit(1)
170
+ .then((rows) => rows[0]);
171
+ if (anchor === undefined) {
172
+ throw new Error(`No live workflow run for address "${agentAddress}"; cannot register signal correlation ${correlationId}`);
173
+ }
174
+ const addressSlug = deriveWorkflowRunRepoId(agentAddress);
175
+ if (addressSlug !== anchorRunId) {
176
+ throw new Error(`Anchor run id mismatch registering signal correlation ${correlationId}: frame claims "${anchorRunId}" but address "${agentAddress}" derives the workflow-run repo slug "${addressSlug}"`);
177
+ }
178
+ const tenantId = anchor.tenantId;
179
+ const definitionId = anchor.definitionId;
180
+ // Lazily anchor the run before its correlation and approval reference
181
+ // it. A workflow-spawned internal run never crosses the external
182
+ // trigger route that mints a run principal, so its run row would
183
+ // otherwise not exist; ensure it here so the co-written rows have a
184
+ // referent. The principal is null: an internal run inherits its
185
+ // deployment's grants and has no principal of its own. The insert is
186
+ // idempotent on the run id, so a redelivered register frame -- the same
187
+ // redelivery the co-writes below tolerate -- is a no-op.
188
+ await workflowRunStore.createIfAbsent({
189
+ id: runId,
190
+ anchorRunId: anchor.id,
191
+ definitionId,
192
+ tenantId,
193
+ principalId: null,
194
+ status: "running",
195
+ }, tx);
196
+ await signalCorrelationStore.registerIfAbsent({
197
+ correlationId,
198
+ tenantId,
199
+ anchorRunId: anchor.id,
200
+ agentAddress,
201
+ runId,
202
+ signalName: signalName(correlationId),
203
+ kind,
204
+ }, tx);
205
+ await approvalStore.createIfAbsent({
206
+ id: generateId("approval"),
207
+ tenantId,
208
+ anchorRunId: anchor.id,
209
+ runId,
210
+ agentAddress,
211
+ correlationId,
212
+ status: "pending",
213
+ // The register frame guarantees the snapshot (the ask rail is its
214
+ // only producer), so the approver-facing columns are always
215
+ // populated -- never null on this path.
216
+ toolDefinition: {
217
+ name: approvalSnapshot.name,
218
+ description: approvalSnapshot.description,
219
+ inputSchema: approvalSnapshot.inputSchema,
220
+ },
221
+ toolArguments: approvalSnapshot.arguments,
222
+ scope: null,
223
+ timeoutAt: null,
224
+ }, tx);
225
+ });
226
+ },
227
+ async receiveAgentStatePack(repoId, pack, ref, commitSha) {
228
+ if (repoId.kind !== "agent-state") {
229
+ throw new Error(`hub-session lookups receiveAgentStatePack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
230
+ }
231
+ const agentAddress = repoId.id;
232
+ const agentId = parseAgentId(agentAddress);
233
+ try {
234
+ await agentRepoStore.receiveAgentStatePack({ kind: "agent-state", id: agentId }, pack, ref, commitSha);
235
+ }
236
+ catch (err) {
237
+ const msg = err instanceof Error ? err.message : String(err);
238
+ if (msg.startsWith("path_violation")) {
239
+ logger.warn `State pack rejected for ${agentAddress}: ${msg}`;
240
+ return { accepted: false, reason: "path_violation" };
241
+ }
242
+ // Any other failure from the repo subsystem reaches the
243
+ // WebSocket handler as an unhandled rejection unless we catch
244
+ // it here. Transient failures during receivePack (the agent
245
+ // directory being torn down concurrently with an in-flight
246
+ // pack write, filesystem errors mid-rename, etc.) are
247
+ // recoverable from the sender's perspective — the sender can
248
+ // re-push. Surface every such failure as a structured pack
249
+ // rejection (`corrupt` is the closest existing reason — from
250
+ // the sender's perspective the pack failed to index) and log
251
+ // the underlying error so the cause stays traceable on the
252
+ // hub side.
253
+ logger.error `State pack receive failed for ${agentAddress}: ${msg}`;
254
+ return { accepted: false, reason: "corrupt" };
255
+ }
256
+ return { accepted: true };
257
+ },
258
+ async receiveWorkflowRunPack(repoId, pack, ref, commitSha, source) {
259
+ if (repoId.kind !== "workflow-run") {
260
+ throw new Error(`hub-session lookups receiveWorkflowRunPack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
261
+ }
262
+ const workflowRunRepoId = repoId.id;
263
+ if (deriveWorkflowRunRepoId(source.agentAddress) !== workflowRunRepoId) {
264
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source address does not own the repository`;
265
+ return { accepted: false, reason: "path_violation" };
266
+ }
267
+ const [anchor] = await db
268
+ .select({
269
+ id: workflowRun.id,
270
+ address: workflowRun.address,
271
+ anchorRunId: workflowRun.anchorRunId,
272
+ })
273
+ .from(workflowRun)
274
+ .where(and(eq(workflowRun.address, source.agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
275
+ .limit(1);
276
+ if (anchor === undefined ||
277
+ anchor.anchorRunId !== anchor.id ||
278
+ anchor.address === null) {
279
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source address has no live deployment anchor`;
280
+ return { accepted: false, reason: "path_violation" };
281
+ }
282
+ const anchorAddress = anchor.address;
283
+ let newlyTerminalRuns;
284
+ try {
285
+ if (source.kind === "allocated") {
286
+ newlyTerminalRuns = await db.transaction(async (tx) => {
287
+ const [allocation] = await tx
288
+ .select()
289
+ .from(sidecarAllocation)
290
+ .where(eq(sidecarAllocation.anchorRunId, anchor.id))
291
+ .limit(1)
292
+ .for("update");
293
+ if (allocation === undefined ||
294
+ allocation.id !== source.allocationId ||
295
+ allocation.anchorRunId !== source.anchorRunId ||
296
+ source.anchorRunId !== anchor.id ||
297
+ allocation.status !== "allocated" ||
298
+ allocation.generation !== source.generation ||
299
+ allocation.ensureAcceptedGeneration !== source.generation) {
300
+ return null;
301
+ }
302
+ // Replacement advances this same row. Keep its lock until the
303
+ // repository ref has advanced so ownership cannot change after
304
+ // validation but before the old worker's pack becomes
305
+ // authoritative.
306
+ return agentRepoStore.receiveWorkflowRunPack({ kind: "workflow-run", id: workflowRunRepoId }, pack, ref, commitSha);
307
+ });
308
+ if (newlyTerminalRuns === null) {
309
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source connection does not own the deployment's current allocation`;
310
+ return { accepted: false, reason: "path_violation" };
311
+ }
312
+ }
313
+ else {
314
+ const allocation = await db.query.sidecarAllocation.findFirst({
315
+ where: eq(sidecarAllocation.anchorRunId, anchor.id),
316
+ });
317
+ if (allocation !== undefined) {
318
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source connection does not own the deployment's current allocation`;
319
+ return { accepted: false, reason: "path_violation" };
320
+ }
321
+ newlyTerminalRuns = await agentRepoStore.receiveWorkflowRunPack({ kind: "workflow-run", id: workflowRunRepoId }, pack, ref, commitSha);
322
+ }
323
+ }
324
+ catch (err) {
325
+ const msg = err instanceof Error ? err.message : String(err);
326
+ if (msg.startsWith("path_violation")) {
327
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: ${msg}`;
328
+ return { accepted: false, reason: "path_violation" };
329
+ }
330
+ // Mirror the agent-state branch's catch-all: any other failure from
331
+ // the fenced receive (transaction failures, filesystem races,
332
+ // kind-handler diagnostics surfaced as Error messages, etc.) becomes
333
+ // a structured `corrupt` rejection so the sender can re-push, and the
334
+ // underlying error is logged so the cause stays traceable on the hub
335
+ // side.
336
+ logger.error `Workflow-run pack receive failed for ${workflowRunRepoId}: ${msg}`;
337
+ return { accepted: false, reason: "corrupt" };
338
+ }
339
+ // The substrate has already durably advanced the git ref by the time it
340
+ // returns, so the pack is accepted regardless of what happens below. The
341
+ // per-run status flip and principal deactivation are a best-effort
342
+ // downstream side effect of that durable advance, not part of accepting
343
+ // the pack. A failure here leaves the run "running" in the DB with its
344
+ // principal still active; there is no automatic re-fire, because a
345
+ // redelivery of the same durable tip produces no newly-terminal signal
346
+ // (the substrate's per-commit walk short-circuits on an already-present
347
+ // tip). The failure is therefore logged at ERROR as the only record that
348
+ // the row needs a manual flip, and the pack verdict stays accepted so the
349
+ // sidecar is acked and does not wedge re-pushing a pack that already
350
+ // landed.
351
+ const now = new Date();
352
+ for (const { runId, status } of newlyTerminalRuns) {
353
+ try {
354
+ await db.transaction(async (tx) => {
355
+ const [ownedRun] = await tx
356
+ .select({ anchorRunId: workflowRun.anchorRunId })
357
+ .from(workflowRun)
358
+ .where(eq(workflowRun.id, runId))
359
+ .limit(1);
360
+ if (ownedRun?.anchorRunId !== anchor.id) {
361
+ logger.error `Ignoring terminal event for run ${runId}: it does not belong to source deployment ${anchor.id}`;
362
+ return;
363
+ }
364
+ const won = await workflowRunStore.markTerminal(runId, status, now, tx);
365
+ if (won === null) {
366
+ // No running row matched. Either the run is already terminal (a
367
+ // benign replay against an already-settled row) or no row exists
368
+ // at all -- the run reached a terminal event before its anchor
369
+ // committed, so its terminal state has nowhere to land. Only the
370
+ // second case is a defect; distinguish them and log the missing
371
+ // anchor loudly rather than silently treating both as done.
372
+ const [existing] = await tx
373
+ .select({ id: workflowRun.id })
374
+ .from(workflowRun)
375
+ .where(eq(workflowRun.id, runId));
376
+ if (existing === undefined) {
377
+ logger.error `Terminal event for run ${runId} (deployment ${anchor.id}, target status ${status}) has no workflow_run row; the run terminated before its anchor committed`;
378
+ }
379
+ return;
380
+ }
381
+ // Deactivate the run's own principal, if it has one. Externally-
382
+ // triggered runs carry a principal; internal, workflow-spawned runs
383
+ // have `principalId = null` and inherit the deployment's grants, so
384
+ // there is nothing to deactivate. Deactivation is gated on winning
385
+ // the flip -- the single claim point -- not on the principal's own
386
+ // status.
387
+ if (won.principalId !== null) {
388
+ await tx
389
+ .update(principal)
390
+ .set({ status: "deactivated", updatedAt: now })
391
+ // The `refId` clause is a defensive mirror of the per-instance
392
+ // teardown in instances.ts: `won.principalId` is already this
393
+ // run's own principal, and `principal.id` is the primary key,
394
+ // so the `refId` match is belt-and-suspenders that the id we
395
+ // won belongs to this run.
396
+ .where(and(eq(principal.id, won.principalId), eq(principal.refId, runId)));
397
+ }
398
+ });
399
+ }
400
+ catch (err) {
401
+ // Per-run isolation: a failed flip for one run must not abort the
402
+ // rest of the batch, and must not throw out of this method -- a throw
403
+ // would leave the sidecar with neither an ack nor a reject for a pack
404
+ // the substrate already accepted. This ERROR is the only signal that
405
+ // the run is stuck "running" in the DB with its principal active, so
406
+ // it carries enough to find and flip the row by hand.
407
+ const msg = err instanceof Error ? err.message : String(err);
408
+ logger.error `Terminal DB flip failed for run ${runId} (deployment ${anchor.id}, target status ${status}); run left running in the DB: ${msg}`;
409
+ }
410
+ }
411
+ // A sidecar-local mail ack is only receipt. The raw trigger remains in
412
+ // workflow_run_dispatch until the accepted Git tip proves either that
413
+ // the run recorded it (RunStarted / SignalReceived), or that the
414
+ // supervisor consumed it with an explicit rejection. Rescan the bounded
415
+ // retained claim-check index after every accepted pack, and search the
416
+ // stable run log newest-first only for currently-unsettled ids. Settlement
417
+ // is idempotent, and a later pack naturally retries a transient database
418
+ // failure here.
419
+ let topLevelTerminalSettlementProjected = false;
420
+ try {
421
+ const reads = await agentRepoStore.repoStore.openCommittedReads({ kind: "hub" }, repoId, ref);
422
+ if (reads !== null) {
423
+ const unsettledDispatches = await workflowRunDispatchStore.listUnsettled(anchor.id);
424
+ const unsettledByMessageId = new Map(unsettledDispatches.map((dispatch) => [
425
+ dispatch.messageId,
426
+ dispatch,
427
+ ]));
428
+ for (const consumed of await listConsumedWorkflowDispatches(reads)) {
429
+ if (consumed.address !== anchorAddress)
430
+ continue;
431
+ const persisted = unsettledByMessageId.get(consumed.messageId);
432
+ if (persisted?.kind !== "mail")
433
+ continue;
434
+ if (consumed.rejection === undefined) {
435
+ await workflowRunDispatchStore.settle(anchor.id, consumed.messageId, now);
436
+ }
437
+ else {
438
+ await workflowRunDispatchStore.fail({
439
+ anchorRunId: anchor.id,
440
+ messageId: consumed.messageId,
441
+ code: consumed.rejection.code,
442
+ message: consumed.rejection.message,
443
+ now,
444
+ });
445
+ }
446
+ unsettledByMessageId.delete(consumed.messageId);
447
+ }
448
+ // Mail is recorded on the stable deployment run, while a signal is
449
+ // recorded on the exact run named by its durable delivery frame.
450
+ // Group retained dispatches by that Git run before scanning so an
451
+ // internal run's SignalReceived evidence settles its own dispatch.
452
+ // The `runs/<runId>/` log keys on the run id (the address local
453
+ // part), NOT the full address: post-collapse the top-level run's id
454
+ // IS `anchor.id`, so mail keys on `anchor.id`; a signal keys on its
455
+ // frame's own run id. (The `addresses/<address>/` consumed subtree
456
+ // above keys on the full address -- a different subtree.)
457
+ const messageIdsByRun = new Map();
458
+ for (const dispatch of unsettledByMessageId.values()) {
459
+ const runId = dispatch.kind === "mail"
460
+ ? anchor.id
461
+ : SignalDeliverFrame.assert(JSON.parse(new TextDecoder().decode(dispatch.rawMessage))).runId;
462
+ const messageIds = messageIdsByRun.get(runId) ?? new Set();
463
+ messageIds.add(dispatch.messageId);
464
+ messageIdsByRun.set(runId, messageIds);
465
+ }
466
+ for (const [runId, messageIds] of messageIdsByRun) {
467
+ for (const accepted of await listAcceptedWorkflowDispatches(reads, runId, messageIds)) {
468
+ const persisted = unsettledByMessageId.get(accepted.messageId);
469
+ if (persisted?.kind !== accepted.kind)
470
+ continue;
471
+ await workflowRunDispatchStore.settle(anchor.id, accepted.messageId, now);
472
+ unsettledByMessageId.delete(accepted.messageId);
473
+ }
474
+ }
475
+ topLevelTerminalSettlementProjected =
476
+ (await readCommittedWorkflowRunLifecycle(reads, anchor.id)) ===
477
+ "terminal";
478
+ }
479
+ }
480
+ catch (error) {
481
+ logger.error `Workflow dispatch settlement failed for ${anchor.id}; accepted Git state remains authoritative and the retained payload will be retried: ${error instanceof Error ? error.message : String(error)}`;
482
+ }
483
+ if (topLevelTerminalSettlementProjected) {
484
+ try {
485
+ await workflowRunDispatchStore.failUnsettled(anchor.id, "workflow_run_terminal", `Workflow run ${anchorAddress} is terminal and cannot accept this dispatch`, now);
486
+ }
487
+ catch (error) {
488
+ logger.error `Failed to close unsettled workflow dispatches for terminal run ${anchorAddress}: ${error instanceof Error ? error.message : String(error)}`;
489
+ }
490
+ }
491
+ return { accepted: true };
492
+ },
493
+ };
494
+ }
495
+ /**
496
+ * Extract the run id from an `<runId>@<domain>` run address.
497
+ * Throws on any input the `@intx/types`-owned `parseRunAddress`
498
+ * rejects: missing or leading `@`, empty domain, or a run id
499
+ * without the canonical `run_` prefix.
500
+ */
501
+ export function parseAgentId(agentAddress) {
502
+ const parsed = parseRunAddress(agentAddress);
503
+ if (parsed === null) {
504
+ throw new Error(`Invalid run address: "${agentAddress}"`);
505
+ }
506
+ return parsed.runId;
507
+ }
508
+ /**
509
+ * Resolve a run address to the `workflow_run` endpoint backing it, keyed by
510
+ * the run's `address`. Every routable address names one self-anchored run --
511
+ * the deployment's anchor -- so this resolves the run's own address (the
512
+ * source `persistMail` depends on to record a triggered deployment's mail).
513
+ */
514
+ export async function resolveRoutableAddress(db, address) {
515
+ const runRow = await db
516
+ .select({
517
+ id: workflowRun.id,
518
+ tenantId: workflowRun.tenantId,
519
+ publicKey: workflowRun.publicKey,
520
+ status: workflowRun.status,
521
+ principalId: workflowRun.principalId,
522
+ })
523
+ .from(workflowRun)
524
+ .where(and(eq(workflowRun.address, address), isNull(workflowRun.endedAt)))
525
+ .limit(1)
526
+ .then((rows) => rows[0]);
527
+ if (runRow === undefined) {
528
+ return undefined;
529
+ }
530
+ return {
531
+ id: runRow.id,
532
+ tenantId: runRow.tenantId,
533
+ address,
534
+ publicKey: runRow.publicKey,
535
+ status: runRow.status,
536
+ sessionId: await resolveRunSessionId(db, runRow.principalId),
537
+ };
538
+ }
539
+ /**
540
+ * A folded run has no session column; its session is the `agent_session` keyed
541
+ * by the run's principal. By default this is the live (not-yet-ended) session,
542
+ * matching routing semantics; `includeEnded` also resolves a stopped run's
543
+ * ended session, which mail history needs. Returns null when the run has no
544
+ * principal or no matching session. Transitional, alongside
545
+ * `RoutableEndpoint.sessionId`.
546
+ */
547
+ export async function resolveRunSessionId(db, principalId, opts = {}) {
548
+ if (principalId === null) {
549
+ return null;
550
+ }
551
+ // One session per run principal (invariant), so limit(1) returns the whole
552
+ // history; order deterministically so a hypothetical second row cannot make
553
+ // the pick flap. If a run ever grows multiple sessions per principal this
554
+ // becomes a union and limit(1) silently truncates.
555
+ const conditions = [eq(agentSession.principalId, principalId)];
556
+ if (opts.includeEnded !== true) {
557
+ conditions.push(isNull(agentSession.endedAt));
558
+ }
559
+ const row = await db
560
+ .select({ id: agentSession.id })
561
+ .from(agentSession)
562
+ .where(and(...conditions))
563
+ .orderBy(asc(agentSession.createdAt))
564
+ .limit(1)
565
+ .then((rows) => rows[0]);
566
+ return row?.id ?? null;
567
+ }
568
+ /**
569
+ * The folded run that owns a session, or null when the session belongs to no
570
+ * run. This is the inverse of `resolveRunSessionId`: a mail-read path holds a
571
+ * `sessionMail.sessionId` and no address, so it recovers the owning run by
572
+ * joining `workflow_run` to `agent_session` on their shared principal (a folded
573
+ * run, its session, and its launch all key on the same `instancePrincipalId`).
574
+ * Scoped to the tenant and routed through `workflow_run` so the returned id is
575
+ * proven to name a real run of this tenant -- callers key an authorization
576
+ * subject on it, so a session held by a non-run principal must fail closed to
577
+ * null rather than resolve to a fabricated subject.
578
+ */
579
+ export async function resolveRunIdForSession(db, sessionId, tenantId) {
580
+ // No `endedAt` filter: a stopped run's mail must stay fetchable, so the
581
+ // session resolves whether or not it has ended. The run principal is minted
582
+ // per launch and shared 1:1 by the run and its session, so at most one row
583
+ // matches; order deterministically anyway so a hypothetical second row cannot
584
+ // make the pick flap, mirroring `resolveRunSessionId`.
585
+ const row = await db
586
+ .select({ id: workflowRun.id })
587
+ .from(workflowRun)
588
+ .innerJoin(agentSession, eq(agentSession.principalId, workflowRun.principalId))
589
+ .where(and(eq(agentSession.id, sessionId), eq(workflowRun.tenantId, tenantId)))
590
+ .orderBy(asc(workflowRun.createdAt))
591
+ .limit(1)
592
+ .then((rows) => rows[0]);
593
+ return row?.id ?? null;
594
+ }
595
+ /**
596
+ * Shape a run row and its already-resolved routing address into the run
597
+ * record. Callers decide whether the run resolves at all -- only a top-level
598
+ * run (`isTopLevelRun`) does -- and pass the address they have narrowed; this
599
+ * only maps the columns, including the run's `endedAt ?? createdAt` stand-in
600
+ * for the absent `updatedAt`.
601
+ */
602
+ export function runRowToRoutableRecord(run, address) {
603
+ return {
604
+ id: run.id,
605
+ tenantId: run.tenantId,
606
+ address,
607
+ publicKey: run.publicKey,
608
+ status: run.status,
609
+ createdAt: run.createdAt,
610
+ updatedAt: run.endedAt ?? run.createdAt,
611
+ endedAt: run.endedAt,
612
+ definitionId: run.definitionId,
613
+ principalId: run.principalId,
614
+ kernelId: run.kernelId,
615
+ sidecarId: run.sidecarId,
616
+ };
617
+ }
618
+ /**
619
+ * A run is a top-level run -- the addressable head of a deployment -- when it
620
+ * owns a routing address AND self-anchors (`anchorRunId === id`). A lazy child
621
+ * park row anchors on its parent (`anchorRunId !== id`) and carries no address;
622
+ * either condition excludes it. This is the single predicate the run read
623
+ * surface classifies on, so the resolver and the run list cannot drift.
624
+ */
625
+ export function isTopLevelRun(row) {
626
+ return row.address !== null && row.anchorRunId === row.id;
627
+ }
628
+ /**
629
+ * Resolve a run id to its record. A run resolves only when it is a top-level
630
+ * run (`isTopLevelRun`): it owns a routing address and self-anchors. A child
631
+ * park row (address-null, anchored on its parent) is not served here.
632
+ */
633
+ export async function findRoutableById(db, id, tenantId) {
634
+ const runRow = await db
635
+ .select({
636
+ id: workflowRun.id,
637
+ tenantId: workflowRun.tenantId,
638
+ address: workflowRun.address,
639
+ anchorRunId: workflowRun.anchorRunId,
640
+ publicKey: workflowRun.publicKey,
641
+ status: workflowRun.status,
642
+ createdAt: workflowRun.createdAt,
643
+ endedAt: workflowRun.endedAt,
644
+ principalId: workflowRun.principalId,
645
+ kernelId: workflowRun.kernelId,
646
+ sidecarId: workflowRun.sidecarId,
647
+ definitionId: workflowRun.definitionId,
648
+ })
649
+ .from(workflowRun)
650
+ .where(and(eq(workflowRun.id, id), eq(workflowRun.tenantId, tenantId)))
651
+ .limit(1)
652
+ .then((rows) => rows[0]);
653
+ // The `address === null` arm is redundant with `isTopLevelRun` (which already
654
+ // requires a non-null address) but narrows `address` from `string | null` to
655
+ // `string` for `runRowToRoutableRecord`, which `isTopLevelRun`'s boolean
656
+ // return cannot do.
657
+ if (runRow === undefined ||
658
+ !isTopLevelRun(runRow) ||
659
+ runRow.address === null) {
660
+ return undefined;
661
+ }
662
+ return runRowToRoutableRecord(runRow, runRow.address);
663
+ }