@intx/hub-sessions 0.2.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 (73) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +9 -5
  3. package/dist/agent-repo.js +2 -2
  4. package/dist/agent-state-kind.js +4 -0
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +9 -91
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +7 -6
  10. package/dist/credential-push.js +42 -18
  11. package/dist/event-collector-registry.d.ts +1 -1
  12. package/dist/event-collector-registry.js +4 -4
  13. package/dist/event-collector.d.ts +1 -1
  14. package/dist/event-collector.js +10 -2
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +539 -80
  17. package/dist/hub-session-orchestrator.js +14 -49
  18. package/dist/index.d.ts +17 -8
  19. package/dist/index.js +14 -6
  20. package/dist/repo-store/index.d.ts +1 -1
  21. package/dist/repo-store/store.d.ts +1 -1
  22. package/dist/repo-store/store.js +138 -1
  23. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  24. package/dist/repo-store/subscribe-kind.js +42 -77
  25. package/dist/repo-store/types.d.ts +94 -6
  26. package/dist/session-service.d.ts +277 -96
  27. package/dist/session-service.js +741 -547
  28. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  29. package/dist/sidecar-allocation/contracts.js +21 -0
  30. package/dist/sidecar-allocation/index.d.ts +4 -0
  31. package/dist/sidecar-allocation/index.js +3 -0
  32. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  33. package/dist/sidecar-allocation/placement-policy.js +21 -0
  34. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  35. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  36. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  37. package/dist/sidecar-allocation/reconciler.js +431 -0
  38. package/dist/skill-kind.js +4 -0
  39. package/dist/substrate.d.ts +3 -3
  40. package/dist/substrate.js +1 -1
  41. package/dist/workflow-allocation-service.d.ts +58 -0
  42. package/dist/workflow-allocation-service.js +239 -0
  43. package/dist/workflow-closure-resolution.d.ts +106 -0
  44. package/dist/workflow-closure-resolution.js +123 -0
  45. package/dist/workflow-definition-ensure.d.ts +24 -0
  46. package/dist/workflow-definition-ensure.js +75 -0
  47. package/dist/workflow-dispatch-service.d.ts +40 -0
  48. package/dist/workflow-dispatch-service.js +146 -0
  49. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  50. package/dist/workflow-dispatch-settlement.js +140 -0
  51. package/dist/workflow-kind.d.ts +17 -1
  52. package/dist/workflow-kind.js +127 -80
  53. package/dist/workflow-probe-gate.d.ts +214 -0
  54. package/dist/workflow-probe-gate.js +207 -0
  55. package/dist/workflow-run-kind.d.ts +128 -14
  56. package/dist/workflow-run-kind.js +353 -83
  57. package/dist/workflow-run-reader.d.ts +1 -1
  58. package/dist/workflow-run-reader.js +3 -7
  59. package/dist/workflow-run-restore.d.ts +15 -0
  60. package/dist/workflow-run-restore.js +26 -0
  61. package/dist/workflow-source-closure.d.ts +35 -0
  62. package/dist/workflow-source-closure.js +342 -0
  63. package/dist/ws/index.d.ts +3 -3
  64. package/dist/ws/index.js +1 -1
  65. package/dist/ws/sidecar-events.d.ts +100 -12
  66. package/dist/ws/sidecar-events.js +2 -0
  67. package/dist/ws/sidecar-handler.d.ts +128 -7
  68. package/dist/ws/sidecar-handler.js +1069 -135
  69. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  70. package/dist/ws/sidecar-token-authenticator.js +64 -7
  71. package/package.json +14 -13
  72. package/dist/available-skills-stanza.d.ts +0 -21
  73. package/dist/available-skills-stanza.js +0 -32
@@ -5,92 +5,99 @@
5
5
  // Each lookup is a stateless DB or repo call. They are gathered into a
6
6
  // single struct that the hub app passes to `createSidecarRouter` as
7
7
  // `lookups`.
8
- import { eq, and, isNull } from "drizzle-orm";
9
- import { agentInstance, sessionMail, workflowDeployment, } from "@intx/db/schema";
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";
10
11
  import { getLogger } from "@intx/log";
11
- import { parseAgentAddress } from "@intx/types";
12
- import { isWorkflowDerivedAddress } from "@intx/workflow-deploy";
12
+ import { parseRunAddress, signalName } from "@intx/types";
13
+ import { SignalDeliverFrame } from "@intx/types/sidecar";
14
+ import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy";
13
15
  import { generateId } from "@intx/hub-common";
16
+ import { listAcceptedWorkflowDispatches, listConsumedWorkflowDispatches, } from "./workflow-dispatch-settlement.js";
17
+ import { readCommittedWorkflowRunLifecycle } from "./workflow-run-kind.js";
14
18
  const logger = getLogger(["hub", "lookups"]);
15
19
  export function createHubSessionLookups(deps) {
16
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);
17
25
  return {
18
26
  async lookupPublicKey(agentAddress) {
19
- // Route by address space, not a blind two-table fallback: a
20
- // workflow-derived address's key lives on its workflow_deployment
21
- // row, a launched agent's on its agent_instance row, and the two
22
- // spaces are disjoint. Routing (rather than falling back) means a
23
- // launched agent that is missing its instance row returns null and
24
- // fails its challenge visibly, instead of silently resolving against
25
- // the wrong table.
26
- if (isWorkflowDerivedAddress(agentAddress)) {
27
- // Filter to a live ("deployed") deployment so a torn-down
28
- // deployment's key can no longer satisfy a challenge. A null
29
- // publicKey (deployed but not yet acked, or pre-migration) or an
30
- // absent row returns null -- the challenge fails closed and the
31
- // address stays unrouted rather than routing without ownership
32
- // proof.
33
- const row = await db
34
- .select({ publicKey: workflowDeployment.publicKey })
35
- .from(workflowDeployment)
36
- .where(and(eq(workflowDeployment.address, agentAddress), eq(workflowDeployment.status, "deployed")))
37
- .limit(1)
38
- .then((rows) => rows[0]);
39
- return row?.publicKey ?? null;
40
- }
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.
41
37
  const row = await db
42
- .select({ publicKey: agentInstance.publicKey })
43
- .from(agentInstance)
44
- .where(and(eq(agentInstance.address, agentAddress), isNull(agentInstance.endedAt)))
38
+ .select({ publicKey: workflowRun.publicKey })
39
+ .from(workflowRun)
40
+ .where(and(eq(workflowRun.address, agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
45
41
  .limit(1)
46
42
  .then((rows) => rows[0]);
47
43
  return row?.publicKey ?? null;
48
44
  },
49
- async lookupDeployRef(agentAddress) {
50
- const agentId = parseAgentId(agentAddress);
51
- return agentRepoStore.getDeployRef(agentId);
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;
52
50
  },
53
51
  async persistMail({ senderAddress, recipients, raw }) {
54
- const senderInstance = await requireInstance(db, senderAddress);
55
- if (!senderInstance.sessionId) {
56
- throw new Error(`Instance ${senderInstance.id} has no session for address "${senderAddress}"`);
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}"`);
57
62
  }
58
63
  const createdAt = new Date();
64
+ const senderRunId = null;
59
65
  // Outbound record on the sender's session.
60
66
  const outboundId = generateId("sessionMail");
61
67
  const outboundRecord = {
62
68
  id: outboundId,
63
- sessionId: senderInstance.sessionId,
64
- instanceId: senderInstance.id,
65
- tenantId: senderInstance.tenantId,
69
+ sessionId: sender.sessionId,
70
+ runId: senderRunId,
71
+ tenantId: sender.tenantId,
66
72
  direction: "outbound",
67
73
  status: "delivered",
68
74
  raw,
69
75
  createdAt,
70
76
  };
71
- // Inbound records for each recipient that has an active agent
72
- // instance. Recipients that are not agent instances (e.g. human
73
- // user addresses) are skipped.
77
+ // Inbound records for each recipient that is a live endpoint.
78
+ // Recipients that are not (e.g. human user addresses) are skipped.
74
79
  const recipientResults = await Promise.all(recipients.map(async (addr) => {
75
- const row = await findInstance(db, addr);
76
- if (row === undefined) {
80
+ const endpoint = await resolveRoutableAddress(db, addr);
81
+ if (endpoint === undefined) {
77
82
  return null;
78
83
  }
79
- if (row.sessionId === null) {
80
- logger.warn `Active instance ${row.id} for "${addr}" has no session; skipping inbound record`;
84
+ if (endpoint.sessionId === null) {
85
+ logger.warn `Active endpoint ${endpoint.id} for "${addr}" has no session; skipping inbound record`;
81
86
  return null;
82
87
  }
83
- return { addr, instance: row, sessionId: row.sessionId };
88
+ return { addr, endpoint, sessionId: endpoint.sessionId };
84
89
  }));
85
- const recipientInstances = recipientResults.filter((r) => r !== null);
86
- const inboundEntries = recipientInstances.map(({ addr, instance, sessionId }) => {
90
+ const recipientEndpoints = recipientResults.filter((r) => r !== null);
91
+ const inboundEntries = recipientEndpoints.map(({ addr, endpoint, sessionId }) => {
87
92
  const id = generateId("sessionMail");
93
+ // A folded run is not an instance, so its mail records no runId.
94
+ const runId = null;
88
95
  return {
89
96
  record: {
90
97
  id,
91
98
  sessionId,
92
- instanceId: instance.id,
93
- tenantId: instance.tenantId,
99
+ runId,
100
+ tenantId: endpoint.tenantId,
94
101
  direction: "inbound",
95
102
  status: "delivered",
96
103
  raw,
@@ -99,7 +106,7 @@ export function createHubSessionLookups(deps) {
99
106
  result: {
100
107
  id,
101
108
  direction: "inbound",
102
- instanceId: instance.id,
109
+ runId,
103
110
  address: addr,
104
111
  createdAt,
105
112
  },
@@ -112,13 +119,111 @@ export function createHubSessionLookups(deps) {
112
119
  {
113
120
  id: outboundId,
114
121
  direction: "outbound",
115
- instanceId: senderInstance.id,
116
- address: senderInstance.address,
122
+ runId: senderRunId,
123
+ address: sender.address,
117
124
  createdAt,
118
125
  },
119
126
  ...inboundEntries.map((e) => e.result),
120
127
  ];
121
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
+ },
122
227
  async receiveAgentStatePack(repoId, pack, ref, commitSha) {
123
228
  if (repoId.kind !== "agent-state") {
124
229
  throw new Error(`hub-session lookups receiveAgentStatePack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
@@ -150,55 +255,409 @@ export function createHubSessionLookups(deps) {
150
255
  }
151
256
  return { accepted: true };
152
257
  },
153
- async receiveWorkflowRunPack(repoId, pack, ref, commitSha) {
258
+ async receiveWorkflowRunPack(repoId, pack, ref, commitSha, source) {
154
259
  if (repoId.kind !== "workflow-run") {
155
260
  throw new Error(`hub-session lookups receiveWorkflowRunPack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
156
261
  }
157
- const deploymentId = repoId.id;
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;
158
284
  try {
159
- await agentRepoStore.receiveWorkflowRunPack({ kind: "workflow-run", id: deploymentId }, pack, ref, commitSha);
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
+ }
160
323
  }
161
324
  catch (err) {
162
325
  const msg = err instanceof Error ? err.message : String(err);
163
326
  if (msg.startsWith("path_violation")) {
164
- logger.warn `Workflow-run pack rejected for ${deploymentId}: ${msg}`;
327
+ logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: ${msg}`;
165
328
  return { accepted: false, reason: "path_violation" };
166
329
  }
167
- // Mirror the agent-state branch's catch-all: any other failure
168
- // from the repo subsystem (filesystem races, kind-handler
169
- // diagnostics surfaced as Error messages, etc.) becomes a
170
- // structured `corrupt` rejection so the sender can re-push,
171
- // and the underlying error is logged so the cause stays
172
- // traceable on the hub side.
173
- logger.error `Workflow-run pack receive failed for ${deploymentId}: ${msg}`;
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}`;
174
337
  return { accepted: false, reason: "corrupt" };
175
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
+ }
176
491
  return { accepted: true };
177
492
  },
178
493
  };
179
494
  }
180
495
  /**
181
- * Extract the instance id from an `<instanceId>@<domain>` agent address.
182
- * Throws on any input the `@intx/types`-owned `parseAgentAddress`
183
- * rejects: missing or leading `@`, empty domain, or an instance id
184
- * without the canonical `ins_` prefix.
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.
185
500
  */
186
501
  export function parseAgentId(agentAddress) {
187
- const parsed = parseAgentAddress(agentAddress);
502
+ const parsed = parseRunAddress(agentAddress);
188
503
  if (parsed === null) {
189
- throw new Error(`Invalid agent address: "${agentAddress}"`);
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));
190
558
  }
191
- return parsed.instanceId;
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;
192
567
  }
193
- export async function findInstance(db, agentAddress) {
194
- return db.query.agentInstance.findFirst({
195
- where: and(eq(agentInstance.address, agentAddress), isNull(agentInstance.endedAt)),
196
- });
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;
197
627
  }
198
- export async function requireInstance(db, agentAddress) {
199
- const row = await findInstance(db, agentAddress);
200
- if (!row) {
201
- throw new Error(`No active instance found for address "${agentAddress}"`);
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;
202
661
  }
203
- return row;
662
+ return runRowToRoutableRecord(runRow, runRow.address);
204
663
  }