@intx/hub-sessions 0.3.0 → 0.4.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 (67) hide show
  1. package/dist/agent-repo.d.ts +14 -2
  2. package/dist/agent-repo.js +17 -4
  3. package/dist/agent-state-kind.js +14 -63
  4. package/dist/asset-service.js +14 -10
  5. package/dist/credential-push.d.ts +48 -4
  6. package/dist/credential-push.js +138 -6
  7. package/dist/event-collector-registry.d.ts +2 -1
  8. package/dist/event-collector-registry.js +38 -9
  9. package/dist/event-collector.d.ts +11 -1
  10. package/dist/event-collector.js +36 -3
  11. package/dist/hub-session-lookups.d.ts +1 -1
  12. package/dist/hub-session-lookups.js +68 -72
  13. package/dist/hub-session-orchestrator.d.ts +2 -3
  14. package/dist/hub-session-orchestrator.js +13 -12
  15. package/dist/index.d.ts +7 -6
  16. package/dist/index.js +7 -6
  17. package/dist/reconciliation-scheduler.d.ts +14 -0
  18. package/dist/reconciliation-scheduler.js +55 -0
  19. package/dist/repo-store/index.d.ts +1 -0
  20. package/dist/repo-store/index.js +1 -0
  21. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  22. package/dist/repo-store/user-principal-gate.js +78 -0
  23. package/dist/session-service.d.ts +66 -121
  24. package/dist/session-service.js +444 -411
  25. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  26. package/dist/sidecar-allocation/capability-policy.js +124 -0
  27. package/dist/sidecar-allocation/contracts.d.ts +29 -6
  28. package/dist/sidecar-allocation/contracts.js +7 -2
  29. package/dist/sidecar-allocation/index.d.ts +4 -3
  30. package/dist/sidecar-allocation/index.js +3 -2
  31. package/dist/sidecar-allocation/operation.d.ts +10 -0
  32. package/dist/sidecar-allocation/operation.js +54 -0
  33. package/dist/sidecar-allocation/plugin-registry.d.ts +16 -3
  34. package/dist/sidecar-allocation/plugin-registry.js +36 -12
  35. package/dist/sidecar-allocation/reconciler.d.ts +16 -4
  36. package/dist/sidecar-allocation/reconciler.js +486 -92
  37. package/dist/skill-kind.js +8 -62
  38. package/dist/substrate.d.ts +1 -1
  39. package/dist/substrate.js +1 -1
  40. package/dist/workflow-allocation-service.d.ts +21 -15
  41. package/dist/workflow-allocation-service.js +440 -125
  42. package/dist/workflow-dispatch-service.d.ts +4 -2
  43. package/dist/workflow-dispatch-service.js +89 -26
  44. package/dist/workflow-kind.d.ts +12 -0
  45. package/dist/workflow-kind.js +17 -60
  46. package/dist/workflow-probe-gate.d.ts +99 -27
  47. package/dist/workflow-probe-gate.js +196 -21
  48. package/dist/workflow-run-kind.d.ts +112 -19
  49. package/dist/workflow-run-kind.js +626 -210
  50. package/dist/workflow-run-restore.d.ts +1 -0
  51. package/dist/workflow-run-restore.js +5 -1
  52. package/dist/workflow-source-pins.d.ts +8 -0
  53. package/dist/workflow-source-pins.js +14 -0
  54. package/dist/ws/index.d.ts +1 -1
  55. package/dist/ws/index.js +1 -1
  56. package/dist/ws/pending-tracker.d.ts +93 -0
  57. package/dist/ws/pending-tracker.js +132 -0
  58. package/dist/ws/sidecar-events.d.ts +43 -29
  59. package/dist/ws/sidecar-events.js +0 -2
  60. package/dist/ws/sidecar-handler.d.ts +122 -85
  61. package/dist/ws/sidecar-handler.js +925 -878
  62. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  63. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  64. package/dist/ws/sidecar-token-authenticator.js +37 -23
  65. package/package.json +13 -13
  66. package/dist/sidecar-allocation/placement-policy.d.ts +0 -11
  67. package/dist/sidecar-allocation/placement-policy.js +0 -21
@@ -12,11 +12,17 @@
12
12
  // 3. RECOMPUTE the wire hash over the RECEIVED projection as tamper-evidence:
13
13
  // a shipped hash that differs from the hub recompute is rejected, fail
14
14
  // closed, no coercion.
15
- // 4. Gate the advisory grant set against the approval policy: an operator
16
- // `ApprovalSet` requires every grant the probe surfaced to be approved or
17
- // the gate fails, while `approve-probed` approves exactly what the probe
18
- // surfaced.
19
- // 5. Freeze the approved wire hash onto the definition version row, keyed by
15
+ // 4. Reject a projection declaring a trigger type the runtime does not
16
+ // implement, so a deployment that could only ever sit inert never gets
17
+ // approved.
18
+ // 5. Reject a projection carrying a step the grant walk left no record for,
19
+ // so no deployment can schedule a step whose grants nobody approved.
20
+ // 6. Gate the advisory grant set AND the definition's declared grant
21
+ // requirements against the approval policy: an operator `ApprovalSet`
22
+ // requires every grant the probe surfaced and every requirement it
23
+ // declared to be approved or the gate fails, while `approve-probed`
24
+ // approves exactly what the probe surfaced.
25
+ // 7. Freeze the approved wire hash onto the definition version row, keyed by
20
26
  // the definition's selector, and return the frozen approved grant set.
21
27
  //
22
28
  // The frozen approved set is the single source of truth for the definition's
@@ -25,9 +31,12 @@
25
31
  // did not have frozen at approval. The wire hash is that freeze's anchor -- the
26
32
  // grant set is a deterministic projection of the exact content the hash
27
33
  // addresses, so pinning the hash pins the set.
34
+ import { type } from "arktype";
28
35
  import { and, eq } from "drizzle-orm";
29
36
  import { workflowDefinitionVersion } from "@intx/db/schema";
30
37
  import { computeWireDefinitionHash } from "@intx/types/wire-definition-hash";
38
+ import { EXECUTABLE_STEP_DESCENT, walkStepTree, } from "@intx/workflow/definition";
39
+ import { approvalSetFromItems, inertNestedBodies, isApprovedGrantRequirement, } from "@intx/workflow-deploy";
31
40
  import { buildSourceAssetMounts, resolveWorkflowClosure, } from "./workflow-closure-resolution.js";
32
41
  import { ensureWorkflowDefinitionForAsset } from "./workflow-definition-ensure.js";
33
42
  // The version `ensureWorkflowDefinitionForAsset` projects for a fresh
@@ -73,18 +82,113 @@ export function createDbFrozenApprovalWriter(db) {
73
82
  };
74
83
  }
75
84
  function isApproveProbed(policy) {
76
- return "mode" in policy;
85
+ return policy.kind === "approve-probed";
86
+ }
87
+ /**
88
+ * Trigger types the definition vocabulary declares but the runtime does not
89
+ * implement. `schedule` is reserved: no cron parser and no scheduler exist, so
90
+ * a schedule-triggered deployment would hash, deploy, and then never fire --
91
+ * no error, no log, no failed run. Admitting one at the gate is the only way a
92
+ * deployment reaches that state, so the gate refuses it.
93
+ */
94
+ const UNIMPLEMENTED_TRIGGER_TYPES = new Set(["schedule"]);
95
+ // A projected trigger, typed only to its discriminant. `triggers` rides the
96
+ // wire projection as `unknown[]` on purpose (the wire envelope does not own
97
+ // the trigger vocabulary), so the discriminant is read through a validator
98
+ // rather than an assertion. An entry that carries no string `type` is not a
99
+ // trigger this gate has an opinion about and is left to the deploy path.
100
+ const ProjectedTriggerType = type({ type: "string" });
101
+ /**
102
+ * The distinct unimplemented trigger types a projection declares, in first-seen
103
+ * order. Empty when every declared trigger has an implementation behind it.
104
+ */
105
+ function collectUnimplementedTriggerTypes(triggers) {
106
+ const found = [];
107
+ for (const trigger of triggers) {
108
+ const parsed = ProjectedTriggerType(trigger);
109
+ if (parsed instanceof type.errors)
110
+ continue;
111
+ if (!UNIMPLEMENTED_TRIGGER_TYPES.has(parsed.type))
112
+ continue;
113
+ if (found.includes(parsed.type))
114
+ continue;
115
+ found.push(parsed.type);
116
+ }
117
+ return found;
118
+ }
119
+ const EXECUTABLE_CLOSURE_CONTEXT = "probe gate executable closure: ";
120
+ /**
121
+ * Every step the deployment can execute, walked over the frozen inert
122
+ * projection under `EXECUTABLE_STEP_DESCENT`, each carrying the chain it was
123
+ * reached through and the top-level step whose grant record accounts for it.
124
+ *
125
+ * The descent is the canonical one rather than a local re-derivation on
126
+ * purpose. Sharing `EXECUTABLE_STEP_DESCENT` with the capability walk makes the
127
+ * two sides agree on ONE thing: which primitive kinds are descended into, so a
128
+ * newly-added container kind cannot become reachable here while staying
129
+ * invisible to the walk. It does NOT make the two agree about grants. This
130
+ * function sees only step positions; whether the walk folded the right grants
131
+ * into a record is not observable from the inert projection and is not checked
132
+ * anywhere on this path.
133
+ *
134
+ * The position each step was reached at is the walk's own `path`, for the same
135
+ * reason. The head of that path is the top-level step the entry descends from,
136
+ * which is exactly the `perStep` key the capability walk folds its grants into.
137
+ */
138
+ function collectExecutableReaches(projection) {
139
+ const reaches = [];
140
+ walkStepTree({
141
+ tree: projection,
142
+ context: EXECUTABLE_CLOSURE_CONTEXT,
143
+ nestedTrees: (step) => inertNestedBodies(step, EXECUTABLE_STEP_DESCENT),
144
+ visit: ({ stepId, path }) => {
145
+ reaches.push({ stepId, recordStepId: path[0], reachedThrough: path });
146
+ },
147
+ });
148
+ return reaches;
149
+ }
150
+ /**
151
+ * Every executable step the grant-walk snapshot carries no record for.
152
+ *
153
+ * A step's approved-grant record is the snapshot entry keyed by the top-level
154
+ * step it descends from: the capability walk collects one record per top-level
155
+ * step and folds into it the grants of every step that step can run.
156
+ *
157
+ * This is a PRESENCE check and nothing more. An absent record is decisive --
158
+ * it leaves every step of that subtree with no approved grants at all, which
159
+ * is the hole this catches. A present record is not evidence the other way:
160
+ * nothing here opens the record to confirm it actually carries the grants the
161
+ * steps beneath it need. A record present but under-filled passes this check
162
+ * and still refuses those tool calls at run time.
163
+ */
164
+ function collectStepsWithoutGrantRecord(projection, snapshot) {
165
+ const recordedStepIds = new Set(snapshot.perStep.map((record) => record.stepId));
166
+ return collectExecutableReaches(projection).filter((reach) => !recordedStepIds.has(reach.recordStepId));
167
+ }
168
+ function describeStepsWithoutGrantRecord(missing) {
169
+ const positions = missing
170
+ .map((missed) => `${missed.stepId} (reached through ${missed.reachedThrough.join(" > ")}; expected a grant-walk record keyed by top-level step ${missed.recordStepId})`)
171
+ .join("; ");
172
+ return `the probe's grant-walk snapshot carries no approved-grant record covering ${String(missing.length)} executable step(s): ${positions}. A step outside every record deploys with no approved grants, and its tool calls are refused at run time with nothing on the deploy path reporting it.`;
77
173
  }
78
174
  /**
79
175
  * Gate a probe result and, on approval, freeze it. Operates purely over the
80
176
  * inert projection and grant set -- no author code runs here and the capability
81
177
  * walk is never re-run.
82
178
  *
83
- * Fails closed on the two security-load-bearing checks before it writes
179
+ * Fails closed on the three security-load-bearing checks before it writes
84
180
  * anything: the recomputed wire hash must match the hash the sidecar shipped
85
- * (tamper-evidence), and every advisory grant must be operator-approved. Only
86
- * then does it freeze the recomputed hash onto the version row and return the
87
- * approved grant set.
181
+ * (tamper-evidence), every advisory grant must be operator-approved, and every
182
+ * declared grant requirement must be operator-approved. It also refuses a
183
+ * projection whose triggers include a reserved-but-unimplemented type -- not a
184
+ * security check, but the layer a pinned closure cannot carry a stale copy of,
185
+ * so it is where a workflow that could only sit inert is caught -- and one
186
+ * whose executable closure reaches a step the grant walk left no record for --
187
+ * the layer holding both halves of the probe answer at once, and a presence
188
+ * check on those records rather than a check that any record's contents are
189
+ * sufficient.
190
+ * Only then does it freeze the recomputed hash onto the version row and return
191
+ * the approved grant set.
88
192
  */
89
193
  export async function gateAndFreezeProbeResult(args) {
90
194
  const { assetId, probeResult, approvals, persist } = args;
@@ -101,20 +205,94 @@ export async function gateAndFreezeProbeResult(args) {
101
205
  recomputedWireHash,
102
206
  };
103
207
  }
208
+ // Reject a trigger type nothing implements. This runs on the projection
209
+ // rather than on the author's definition because `defineWorkflow` is bundled
210
+ // INTO the pinned workflow closure: a closure published before the authoring
211
+ // check carries its own frozen copy and never sees it. The projection's
212
+ // `triggers` are produced by the hub's live->inert projector, so this is the
213
+ // one trigger surface a stale closure cannot carry past. Placed after the
214
+ // wire-hash check so tamper-evidence still decides first -- the projection
215
+ // must be the one the sidecar hashed before its content is reasoned about.
216
+ const unimplementedTriggerTypes = collectUnimplementedTriggerTypes(probeResult.projection.triggers);
217
+ if (unimplementedTriggerTypes.length > 0) {
218
+ return {
219
+ ok: false,
220
+ reason: "unimplemented_trigger",
221
+ unimplementedTriggerTypes,
222
+ };
223
+ }
224
+ // Totality: every step the deployment can execute must have an approved-grant
225
+ // record behind it. The two halves of a probe answer are produced
226
+ // independently -- the projection by the hub's live->inert projector, the
227
+ // grant-walk snapshot by the sidecar's capability walk over the live
228
+ // definition -- and nothing until now compared them. A step the walk skipped
229
+ // still projects, still deploys, and is still scheduled; its tool calls are
230
+ // then refused for lack of any grant, and the tool runner turns that refusal
231
+ // into an error tool result rather than a failure, so the run completes
232
+ // having done none of the work. This is the check that makes that
233
+ // unreachable: it is total over the closure and it runs on every deploy,
234
+ // rather than depending on some test happening to invoke a tool from the
235
+ // affected step.
236
+ //
237
+ // Placed after the trigger check and before the operator-policy checks
238
+ // below. A deploy that trips this is a defect in the deploy path, not a
239
+ // decision the operator can make differently, so it must not be reported
240
+ // behind an unapproved-grant message an operator would act on instead.
241
+ //
242
+ // DO NOT move this assertion earlier in this package's history. The record it
243
+ // requires is a claim that the approval covers everything the step can run,
244
+ // and that claim was not kept for a step inside a loop body or a section body
245
+ // until the deploy and runtime producers were made total over the executable
246
+ // closure. Asserted before those producers, the gate would have been
247
+ // enforcing a guarantee the rest of the system did not honour.
248
+ const stepsWithoutGrantRecord = collectStepsWithoutGrantRecord(probeResult.projection, probeResult.grantWalkSnapshot);
249
+ if (stepsWithoutGrantRecord.length > 0) {
250
+ return {
251
+ ok: false,
252
+ reason: "steps_without_grant_record",
253
+ stepsWithoutGrantRecord,
254
+ message: describeStepsWithoutGrantRecord(stepsWithoutGrantRecord),
255
+ };
256
+ }
104
257
  // Gate the advisory grant set. Under an `ApprovalSet` every grant the probe
105
258
  // surfaced must appear in the operator's approved set; any miss fails the
106
259
  // gate closed. Under `approve-probed` there is no set to gate against -- the
107
260
  // probe's surface IS the approved set -- so nothing is ever unapproved.
108
261
  const unapprovedGrants = isApproveProbed(approvals)
109
262
  ? []
110
- : probeResult.grants.filter((grant) => !approvals.has(grant));
263
+ : probeResult.grants.filter((grant) => !approvals.grants.has(grant));
111
264
  if (unapprovedGrants.length > 0) {
112
265
  return { ok: false, reason: "grants_not_approved", unapprovedGrants };
113
266
  }
114
- // Freeze: the approved set is exactly what the workflow advertised (all of it
115
- // now operator-approved), pinned to the recomputed hash. Persisting the hash
116
- // is the freeze; the grant set is returned for the deploy hand-off.
117
- const approvedGrants = [...probeResult.grants];
267
+ // Gate the DECLARED grant requirements. These ride the walk snapshot rather
268
+ // than the flattened `grants`, and the walk never surfaces them, so the
269
+ // filter above cannot see them -- yet the run path materializes each one into
270
+ // a real grant row on the run principal, and a wildcard row reaches gates no
271
+ // walk-derived row can address. They therefore need the operator's decision
272
+ // on exactly the same terms the advertised grants do. Under `approve-probed`
273
+ // the probe's surface IS the approved surface, so there is nothing to gate
274
+ // against and the requirements ride into the approval below.
275
+ const declaredRequirements = probeResult.grantWalkSnapshot.grantRequirements;
276
+ const unapprovedGrantRequirements = isApproveProbed(approvals)
277
+ ? []
278
+ : declaredRequirements.filter((requirement) => !isApprovedGrantRequirement(approvals, requirement));
279
+ if (unapprovedGrantRequirements.length > 0) {
280
+ return {
281
+ ok: false,
282
+ reason: "grant_requirements_not_approved",
283
+ unapprovedGrantRequirements,
284
+ };
285
+ }
286
+ // Freeze: the approved surface is exactly what the workflow advertised (all
287
+ // of it now operator-approved), pinned to the recomputed hash. Persisting the
288
+ // hash is the freeze; the approved surface is returned for the deploy
289
+ // hand-off. The declared requirements join the walk's grant strings in that
290
+ // surface under both policies, so the record the hand-off and any audit read
291
+ // describes every kind of authority the freeze will mint.
292
+ const approvedGrants = [
293
+ ...probeResult.grants,
294
+ ...declaredRequirements,
295
+ ];
118
296
  const { definitionId } = await persist({
119
297
  assetId,
120
298
  approvedWireHash: recomputedWireHash,
@@ -125,7 +303,7 @@ export async function gateAndFreezeProbeResult(args) {
125
303
  ok: true,
126
304
  definitionId,
127
305
  approvedWireHash: recomputedWireHash,
128
- approvedGrants: new Set(approvedGrants),
306
+ approvedSurface: approvalSetFromItems(approvedGrants),
129
307
  projection: probeResult.projection,
130
308
  };
131
309
  }
@@ -187,16 +365,13 @@ export async function installAndApproveWorkflowDefinition(args) {
187
365
  });
188
366
  assets = [];
189
367
  }
190
- const { sendProbe } = args.router;
191
- if (sendProbe === undefined) {
192
- throw new Error("installAndApproveWorkflowDefinition: router does not support sendProbe");
193
- }
194
- const probeResult = await sendProbe({
368
+ const probeResult = await args.router.sendProbe({
195
369
  source: args.source,
196
370
  closure,
197
371
  entry: args.entry,
198
372
  ...(assets.length > 0 ? { assets } : {}),
199
373
  });
374
+ await args.onProbeResult?.(probeResult);
200
375
  const approval = await gateAndFreezeProbeResult({
201
376
  assetId: args.assetId,
202
377
  probeResult,
@@ -26,6 +26,20 @@ export declare const WORKFLOW_RUN_CONTROL_PREFIX = "control";
26
26
  export declare const WORKFLOW_RUN_INBOX_DIR = "inbox";
27
27
  export declare const WORKFLOW_RUN_PROCESSING_DIR = "processing";
28
28
  export declare const WORKFLOW_RUN_CONSUMED_DIR = "consumed";
29
+ /**
30
+ * Per-run inbound mail-part subtree. Non-text inbound mail content
31
+ * (image/audio/video/document mail parts) is committed here as real
32
+ * files rather than inlined into the JSON event log, whose serialization
33
+ * boundary would corrupt binary bytes. The layout is
34
+ * `runs/<runId>/parts/<urlEncoded(messageId)>/<index>-<name>`: one
35
+ * directory per inbound message (so a long-lived run's successive turns
36
+ * never collide), and one file per mail part carrying its verbatim
37
+ * bytes. The workflow-host ingest writes the bytes and records a
38
+ * lightweight `{ name, contentType, ref }` reference into the run's
39
+ * trigger / signal payload; the step invoker reads the bytes back at
40
+ * `agent.send` time. Files are immutable once written, like `blobs/`.
41
+ */
42
+ export declare const WORKFLOW_RUN_PARTS_DIR = "parts";
29
43
  /**
30
44
  * Filename of the per-address retention watermark blob, a direct child
31
45
  * of `addresses/<urlEncoded>/` (a file, not a directory). Carries the
@@ -82,6 +96,38 @@ export declare const DEFAULT_CONSUMED_RETENTION_MS: number;
82
96
  * `<agentKey>` directory layer below the prefix).
83
97
  */
84
98
  export declare const WORKFLOW_RUN_AGENT_STATE_PREFIX = "agent-state";
99
+ /**
100
+ * Conversational-mailbox subtree for the warm single-step agent. The
101
+ * substrate mailbox backing commits the agent's durable inbox under
102
+ * `mailbox/INBOX/` so the full message history replicates to the hub
103
+ * alongside the run state. The layout is:
104
+ *
105
+ * - `mailbox/INBOX/index.json` — the mailbox index. MUTABLE: the
106
+ * backing rewrites it on every flush, so it is exempt from the
107
+ * retained-blob byte-equality walk the `<uid>.eml` blobs are subject
108
+ * to. It must nonetheless PERSIST once it existed: dropping it drops
109
+ * the whole mailbox and resets uidValidity/uidNext on the next open.
110
+ * - `mailbox/INBOX/<uid>.eml` — one message per file, carrying the
111
+ * raw signed message bytes. `<uid>` is a decimal integer >= 1. A
112
+ * RETAINED `<uid>.eml` (present in both prior and prospective) is
113
+ * opaque and IMMUTABLE: it must reappear byte-identically, exactly
114
+ * like `runs/<runId>/blobs/`. A prior `<uid>.eml` may be ABSENT from
115
+ * the prospective tree — that is the warm agent expunging a message.
116
+ * The raw bytes are not lost: they stay reachable through the parent
117
+ * commit, and a `workflow-run` repo's objects are never GC'd (its
118
+ * kind is off the GC allow-list), so an expunged message survives in
119
+ * history for the life of the run repo. The audit trail rests on
120
+ * "these objects are never pruned", not on the live tree being
121
+ * monotonic.
122
+ *
123
+ * The only entries permitted under `mailbox/` are the `INBOX/`
124
+ * directory; the only entries permitted under `mailbox/INBOX/` are
125
+ * `index.json` and `<uid>.eml` message files. Anything else fails the
126
+ * push.
127
+ */
128
+ export declare const WORKFLOW_RUN_MAILBOX_PREFIX = "mailbox";
129
+ export declare const WORKFLOW_RUN_MAILBOX_INBOX_DIR = "INBOX";
130
+ export declare const WORKFLOW_RUN_MAILBOX_INDEX_FILE = "index.json";
85
131
  /**
86
132
  * Parse the seq from a per-event log filename `<seq>.json` under
87
133
  * `runs/<runId>/events/`. Returns the non-negative integer seq, or
@@ -104,6 +150,15 @@ export declare function parseEventSeq(filename: string): number | null;
104
150
  * offending entry is identifiable.
105
151
  */
106
152
  export declare function requireEventSeq(filename: string, context: string): number;
153
+ /**
154
+ * Maximum byte length of a single mail part path component (the
155
+ * URL-encoded message segment, and each `<index>-<name>` filename).
156
+ * messageIds and mail part names arrive from untrusted inbound mail;
157
+ * an over-long RFC 5322 message-id URL-encodes past the filesystem's
158
+ * 255-byte component limit and would otherwise fail at disk-write time,
159
+ * downstream of validation. Reject it at the boundary instead.
160
+ */
161
+ export declare const MAX_MAIL_PART_PATH_COMPONENT_BYTES = 255;
107
162
  /**
108
163
  * JSON envelope carried by inbox and processing entries. Keys:
109
164
  * - `messageId`: dedup key for the inbound message.
@@ -169,6 +224,35 @@ declare const WatermarkEnvelope: import("arktype/internal/variants/object.ts").O
169
224
  export type ClaimCheckEnvelope = typeof ClaimCheckEnvelope.infer;
170
225
  export type ConsumedEnvelope = typeof ConsumedEnvelope.infer;
171
226
  export type WatermarkEnvelope = typeof WatermarkEnvelope.infer;
227
+ /**
228
+ * Terminal event discriminators mapped to the `workflow_run.status` value
229
+ * each settles the run into. A run whose log contains an entry with one of
230
+ * these `type` values must not receive any event with a strictly greater
231
+ * seq.
232
+ *
233
+ * This map is a hand-rolled copy of the runtime's terminal-run vocabulary
234
+ * (`isTerminalRunPhase` in `@intx/workflow` state-machine `state.ts`,
235
+ * re-exported from the state-machine index and consumed by `transition.ts`),
236
+ * duplicated here because `@intx/hub-sessions` must not depend on
237
+ * `@intx/workflow`. It is the sole authority for that vocabulary and MUST
238
+ * stay in sync with the canonical runtime definition:
239
+ * if the runtime adds or removes a terminal run phase, update this map too.
240
+ * Drift silently reopens the restore-time double-driver collision that
241
+ * `scanRunsForBoot` (below) exists to prevent.
242
+ */
243
+ type TerminalRunStatus = "completed" | "failed" | "cancelled";
244
+ /**
245
+ * Classify a workflow-run event type against the terminal-status vocabulary.
246
+ * `TERMINAL_EVENT_STATUS` is the sole authority (see above), so a type absent
247
+ * from it is by definition not terminal: no separate membership set is
248
+ * consulted and no "unmapped terminal type" case can arise.
249
+ */
250
+ export declare function classifyTerminalEvent(eventType: string): {
251
+ terminal: true;
252
+ status: TerminalRunStatus;
253
+ } | {
254
+ terminal: false;
255
+ };
172
256
  export declare const workflowRunKindHandler: KindHandler;
173
257
  export declare const workflowRunAuthorize: AuthorizeFn;
174
258
  export type EnqueueInboxArgs = {
@@ -310,7 +394,7 @@ export type MarkConsumedArgs = {
310
394
  * Present when the supervisor deliberately refused the message instead of
311
395
  * delivering it to the run. The consumed entry remains the durable dedup
312
396
  * record, while Hub projection uses this detail to fail (rather than settle)
313
- * an exclusive-dispatch row.
397
+ * an allocation-dispatch row.
314
398
  */
315
399
  rejection?: {
316
400
  code: string;
@@ -361,27 +445,36 @@ export type ReplayProcessingToInboxResult = {
361
445
  commitSha: string;
362
446
  replayedKeys: string[];
363
447
  };
448
+ export type ScanRunsForBootResult = {
449
+ ownedMessageIds: Set<string>;
450
+ pendingSealRunIds: string[];
451
+ };
364
452
  /**
365
- * Read the run event logs under `runs/` and return the set of
366
- * `consumedMessageId`s belonging to NON-terminal runs -- the messages a
367
- * live run still owns. The caller (the supervisor's spawn-time replay)
368
- * feeds this into `replayProcessingToInbox`'s `ownedMessageIds` so a
369
- * parked run's message is not re-admitted to inbox and dispatched a
370
- * second time while the run is recovered by re-driving its durable log.
371
- * Without this, the re-drive AND the re-triggered fresh run both re-park
372
- * the same awaitSignal gate on the same runId, and the two concurrent
373
- * runtime bodies race to a corrupt terminal.
453
+ * Walk `runs/` once and return the two boot-recovery inputs the supervisor's
454
+ * spawn needs, from a single traversal of the working tree via `getRepoDir`:
455
+ *
456
+ * - `ownedMessageIds`: the `consumedMessageId`s of NON-terminal runs -- the
457
+ * messages a live run still owns. Spawn feeds this into
458
+ * `replayProcessingToInbox`'s `ownedMessageIds` so a parked run's message is
459
+ * not re-admitted to inbox and dispatched a second time while the run is
460
+ * recovered by re-driving its durable log. Without this, the re-drive AND the
461
+ * re-triggered fresh run both re-park the same awaitSignal gate on the same
462
+ * runId, and the two concurrent runtime bodies race to a corrupt terminal.
463
+ * - `pendingSealRunIds`: runs that are terminal but still in per-event form --
464
+ * an interrupted fold left them unsealed. Spawn hands these to the recovery
465
+ * sweep, which re-runs the idempotent fold. A terminal event is a *proposal*:
466
+ * the authoritative decision is `compactRunEvents`, which independently
467
+ * re-checks the run's max-seq event and no-ops a run that is not actually
468
+ * terminal, so this scan may be loose.
374
469
  *
375
- * Reads the substrate's working tree via `getRepoDir`, mirroring the
376
- * child's `discoverInFlightRuns`. The working tree tracks the run-event
377
- * ref (`refs/heads/main`); the claim-check ref (`refs/heads/events`)
378
- * cannot see it, which is why this lives at the caller rather than inside
379
- * `replayProcessingToInbox`'s single-ref delta. A run whose log is sealed
380
- * (combined `events.json`, only permitted for a terminated run) or
381
- * carries a terminal event is excluded; an absent `runs/` directory
382
- * yields an empty set.
470
+ * The working tree tracks the run-event ref (`refs/heads/main`); the
471
+ * claim-check ref (`refs/heads/events`) cannot see it, which is why this lives
472
+ * at the caller rather than inside `replayProcessingToInbox`'s single-ref
473
+ * delta. A run whose log is sealed (combined `events.jsonl`, only permitted for
474
+ * a terminated run) contributes to neither set; an absent `runs/` directory
475
+ * yields empty results.
383
476
  */
384
- export declare function readOwnedMessageIds(store: RepoStore, repoId: RepoId): Promise<Set<string>>;
477
+ export declare function scanRunsForBoot(store: RepoStore, repoId: RepoId): Promise<ScanRunsForBootResult>;
385
478
  export type WorkflowRunLifecycle = "absent" | "live" | "terminal";
386
479
  /** Read one run's lifecycle from a committed workflow-run tree. */
387
480
  export declare function readCommittedWorkflowRunLifecycle(reads: CommittedReads | null, runId: string): Promise<WorkflowRunLifecycle>;