@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
@@ -151,11 +151,11 @@
151
151
  // Authz:
152
152
  // - `hub` principal: full access.
153
153
  // - `workflow-process` principal: read/write its own deployment's
154
- // event log. The principal carries `{ deploymentId, runId? }`;
155
- // this handler verifies `repoId.id === deploymentId`.
154
+ // event log. The principal carries `{ anchorRunId, runId? }`;
155
+ // this handler verifies `repoId.id === anchorRunId`.
156
156
  // - `supervisor` principal: read/write its own deployment's event
157
- // log. The principal carries `{ deploymentId }`; this handler
158
- // verifies `repoId.id === deploymentId`.
157
+ // log. The principal carries `{ anchorRunId }`; this handler
158
+ // verifies `repoId.id === anchorRunId`.
159
159
  // - `sidecar` principal: read-only (createPack, resolveRef) for
160
160
  // resume.
161
161
  // - `user` principal: gated by bearer-token claims and the route
@@ -173,6 +173,7 @@ export const WORKFLOW_RUN_GITIGNORE_PATH = ".gitignore";
173
173
  export const WORKFLOW_RUN_RUNS_PREFIX = "runs";
174
174
  export const WORKFLOW_RUN_EVENTS_DIR = "events";
175
175
  export const WORKFLOW_RUN_BLOBS_DIR = "blobs";
176
+ export const WORKFLOW_RUN_GRANTS_FILE = "grants.json";
176
177
  export const WORKFLOW_RUN_ADDRESSES_PREFIX = "addresses";
177
178
  export const WORKFLOW_RUN_CONTROL_PREFIX = "control";
178
179
  export const WORKFLOW_RUN_INBOX_DIR = "inbox";
@@ -195,15 +196,28 @@ export const WORKFLOW_RUN_WATERMARK_FILE = "watermark.json";
195
196
  * deduped by a retained consumed entry, short enough that `consumed/`
196
197
  * reaches a bounded steady state of one day's message volume.
197
198
  *
198
- * INVARIANT (operator-owned): the horizon must be >= the longest
199
- * window in which the same `messageId` could legitimately be
200
- * re-submitted and still must be caught as a duplicate. There is no
201
- * automatic internal mail redelivery in the system today, so this is
202
- * the external re-submission window. If an at-least-once redelivery
203
- * source is ever added, the horizon must be >= its maximum redelivery
204
- * window or dedup breaks; a breach surfaces LOUDLY (a too-late
205
- * re-submission carrying an old `receivedAt` is refused at enqueue,
206
- * not silently reprocessed) rather than as silent double-processing.
199
+ * INVARIANT (operator-owned): the horizon must be >= the longest window in
200
+ * which the same `messageId` could legitimately be re-submitted and still must
201
+ * be caught as a duplicate. The hub now redelivers un-acked inbound mail
202
+ * (connected-window retry and reconnect-redelivery), so an at-least-once
203
+ * internal source DOES exist -- but the dedup guarantee against it does not
204
+ * rest on window arithmetic. It rests on a STRUCTURAL fact: `enqueueInbox` is
205
+ * only ever called with a freshly stamped `receivedAt` (a redelivery
206
+ * re-enters `onMailMessage` and re-stamps `Date.now()`, never carrying the
207
+ * original), and the watermark only ever advances to at most
208
+ * `consumedAt - retentionHorizonMs`, which is <= now, so a fresh `receivedAt`
209
+ * always sits a full horizon above the watermark and can never be stale-
210
+ * refused. A redelivery instead hits the `consumed/`/`processing/`/`inbox/`
211
+ * dedup index and is deduped there. The sole path that carries an original
212
+ * (old) `receivedAt` back into the queue is `replayProcessingToInbox`, which
213
+ * writes straight to `inbox/` and bypasses the stale gate entirely. So
214
+ * `claim_check_stale_enqueue` is unreachable via redelivery today. If any
215
+ * redelivery source is ever changed to carry the ORIGINAL `receivedAt` into
216
+ * `enqueueInbox`, stale becomes reachable, the horizon must then be >= that
217
+ * source's maximum redelivery window, and `StaleInboxEnqueueError`'s
218
+ * withhold-not-ack handling becomes load-bearing; a breach surfaces LOUDLY (an
219
+ * old-`receivedAt` re-submission is refused at enqueue) rather than as silent
220
+ * double-processing.
207
221
  */
208
222
  export const DEFAULT_CONSUMED_RETENTION_MS = 24 * 60 * 60 * 1000;
209
223
  /**
@@ -238,6 +252,42 @@ const CLAIM_CHECK_SUBDIRS = new Set([
238
252
  ]);
239
253
  /** Per-event filename shape: a decimal integer followed by `.json`. */
240
254
  const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
255
+ /**
256
+ * Parse the seq from a per-event log filename `<seq>.json` under
257
+ * `runs/<runId>/events/`. Returns the non-negative integer seq, or
258
+ * `null` when the name is not a legal per-event filename. This is the
259
+ * one place the filename shape is defined; every reader of the event log
260
+ * narrows names through it rather than re-encoding the regex. Callers
261
+ * decide what an illegal name means -- a foreign entry to skip, or a
262
+ * substrate-invariant violation to surface -- since `validatePush` is
263
+ * the authority that keeps illegal names from landing in the first place.
264
+ */
265
+ export function parseEventSeq(filename) {
266
+ const match = EVENT_FILENAME_RE.exec(filename);
267
+ if (match === null)
268
+ return null;
269
+ const seqStr = match[1];
270
+ if (seqStr === undefined)
271
+ return null;
272
+ return Number.parseInt(seqStr, 10);
273
+ }
274
+ /**
275
+ * Narrow a per-event filename to its seq, throwing when it is illegal.
276
+ * A reader that enumerates the committed event log to act on its entries
277
+ * uses this rather than `parseEventSeq`: `validatePush` is the authority
278
+ * that keeps an illegal name from ever landing under
279
+ * `runs/<runId>/events/`, so a name that reaches a reader is corruption,
280
+ * and silently skipping it would drop an event from processing. `context`
281
+ * is the repo-root-relative blob path, surfaced in the error so the
282
+ * offending entry is identifiable.
283
+ */
284
+ export function requireEventSeq(filename, context) {
285
+ const seq = parseEventSeq(filename);
286
+ if (seq === null) {
287
+ throw new Error(`event_filename_invalid: ${context}`);
288
+ }
289
+ return seq;
290
+ }
241
291
  /**
242
292
  * Per-blob filename shape for the `runs/<runId>/blobs/` subtree: a
243
293
  * lowercase 64-character sha256 hex string. Pins the regex to the key
@@ -248,11 +298,15 @@ const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
248
298
  */
249
299
  const BLOB_FILENAME_RE = /^[0-9a-f]{64}$/;
250
300
  /**
251
- * Subdirectories the kind handler accepts under `runs/<runId>/`. The
252
- * `events/` subtree carries the append-only event log; the `blobs/`
253
- * subtree carries opaque, content-addressed step outputs the
254
- * `BlobSubstrate` adapter spills there when a value exceeds the
255
- * inline-encoding threshold.
301
+ * Entries the kind handler accepts under `runs/<runId>/`. The `events/`
302
+ * subtree carries the append-only event log; the `blobs/` subtree carries
303
+ * opaque, content-addressed step outputs the `BlobSubstrate` adapter spills
304
+ * there when a value exceeds the inline-encoding threshold; `grants.json`
305
+ * carries the run's authorization grants, delivered by the hub's
306
+ * `run.grants` frame ahead of the trigger and read back by the sidecar's
307
+ * `onRunStart` barrier. The grants file is a run-dir sibling of `events/`,
308
+ * not part of the event log, so the event-shape and blob-immutability walks
309
+ * treat it as inert.
256
310
  */
257
311
  const RUN_DIR_ALLOWED_CHILDREN = new Set([
258
312
  WORKFLOW_RUN_EVENTS_DIR,
@@ -260,6 +314,7 @@ const RUN_DIR_ALLOWED_CHILDREN = new Set([
260
314
  // A terminated run's event log, sealed from the per-event `events/`
261
315
  // files into one combined file by a compaction commit.
262
316
  WORKFLOW_RUN_EVENTS_FILE,
317
+ WORKFLOW_RUN_GRANTS_FILE,
263
318
  ]);
264
319
  /**
265
320
  * Filename shape for inbox and processing entries:
@@ -318,6 +373,10 @@ const ConsumedEnvelope = type({
318
373
  store: "string > 0",
319
374
  path: "string > 0",
320
375
  },
376
+ "rejection?": {
377
+ code: "string > 0",
378
+ message: "string > 0",
379
+ },
321
380
  "+": "ignore",
322
381
  });
323
382
  /**
@@ -332,25 +391,32 @@ const WatermarkEnvelope = type({
332
391
  "+": "ignore",
333
392
  });
334
393
  /**
335
- * Terminal event discriminators. A run whose log contains an entry
336
- * with one of these `type` values must not receive any event with a
337
- * strictly greater seq.
394
+ * Terminal event discriminators mapped to the `workflow_run.status` value
395
+ * each settles the run into. A run whose log contains an entry with one of
396
+ * these `type` values must not receive any event with a strictly greater
397
+ * seq.
338
398
  *
339
- * This set is a hand-rolled copy of the runtime's terminal-run vocabulary
399
+ * This map is a hand-rolled copy of the runtime's terminal-run vocabulary
340
400
  * (`isTerminalRunPhase` in `@intx/workflow` state-machine `state.ts`,
341
- * re-exported from the state-machine index and consumed by
342
- * `transition.ts`), duplicated here because `@intx/hub-sessions` must not
343
- * depend on `@intx/workflow`. It MUST stay in sync with that canonical
344
- * definition:
345
- * if the runtime adds or removes a terminal run phase, update this set too.
401
+ * re-exported from the state-machine index and consumed by `transition.ts`),
402
+ * duplicated here because `@intx/hub-sessions` must not depend on
403
+ * `@intx/workflow`. It is the sole authority for that vocabulary and MUST
404
+ * stay in sync with the canonical runtime definition:
405
+ * if the runtime adds or removes a terminal run phase, update this map too.
346
406
  * Drift silently reopens the restore-time double-driver collision that
347
407
  * `readOwnedMessageIds` (below) exists to prevent.
348
408
  */
349
- const TERMINAL_EVENT_TYPES = new Set([
350
- "RunCompleted",
351
- "RunFailed",
352
- "RunCancelled",
409
+ const TERMINAL_EVENT_STATUS = new Map([
410
+ ["RunCompleted", "completed"],
411
+ ["RunFailed", "failed"],
412
+ ["RunCancelled", "cancelled"],
353
413
  ]);
414
+ /**
415
+ * Membership set of terminal event types, derived from
416
+ * `TERMINAL_EVENT_STATUS` so it always covers exactly the mapped types and
417
+ * the two cannot drift apart.
418
+ */
419
+ const TERMINAL_EVENT_TYPES = new Set(TERMINAL_EVENT_STATUS.keys());
354
420
  /**
355
421
  * Recognised CancelRequested origins. Mirrors the workflow package's
356
422
  * `CANCEL_ORIGINS` vocabulary; inlined here so the substrate does
@@ -406,12 +472,12 @@ const SidecarPrincipal = type({
406
472
  });
407
473
  const WorkflowProcessPrincipal = type({
408
474
  kind: "'workflow-process'",
409
- deploymentId: "string",
475
+ anchorRunId: "string",
410
476
  "runId?": "string",
411
477
  });
412
478
  const SupervisorPrincipal = type({
413
479
  kind: "'supervisor'",
414
- deploymentId: "string",
480
+ anchorRunId: "string",
415
481
  });
416
482
  /**
417
483
  * Resolve the substrate's `changedPathPrefixes` into the set of run ids
@@ -484,7 +550,7 @@ async function enumerateEventBlobs(listDir, scopeRunIds) {
484
550
  if (offender !== undefined) {
485
551
  return {
486
552
  ok: false,
487
- reason: `run directory ${runDirPath} contains unexpected entry ${JSON.stringify(offender)}; only "${WORKFLOW_RUN_EVENTS_DIR}", "${WORKFLOW_RUN_BLOBS_DIR}", and "${WORKFLOW_RUN_EVENTS_FILE}" are allowed`,
553
+ reason: `run directory ${runDirPath} contains unexpected entry ${JSON.stringify(offender)}; only "${WORKFLOW_RUN_EVENTS_DIR}", "${WORKFLOW_RUN_BLOBS_DIR}", "${WORKFLOW_RUN_EVENTS_FILE}", and "${WORKFLOW_RUN_GRANTS_FILE}" are allowed`,
488
554
  };
489
555
  }
490
556
  const hasCombined = runChildren.includes(WORKFLOW_RUN_EVENTS_FILE);
@@ -500,6 +566,16 @@ async function enumerateEventBlobs(listDir, scopeRunIds) {
500
566
  if (hasCombined)
501
567
  continue;
502
568
  if (!hasPerEvent) {
569
+ // A run dir whose only child is `grants.json` is the legitimate
570
+ // pre-first-event window: the hub's `run.grants` frame writes the
571
+ // grants ahead of the trigger, so the grants file lands before the
572
+ // child emits its first event. Carry it forward untouched -- there is
573
+ // no event log to enumerate yet. Any other events-less shape (e.g. a
574
+ // bare `blobs/` with no events) remains rejected below.
575
+ if (runChildren.length === 1 &&
576
+ runChildren[0] === WORKFLOW_RUN_GRANTS_FILE) {
577
+ continue;
578
+ }
503
579
  return {
504
580
  ok: false,
505
581
  reason: `run directory ${runDirPath} is missing required "${WORKFLOW_RUN_EVENTS_DIR}" subdirectory`,
@@ -1768,10 +1844,26 @@ export const workflowRunKindHandler = {
1768
1844
  reason: `event ${entry.blobPath} CancelRequested origin must be a string`,
1769
1845
  };
1770
1846
  }
1771
- const principalCheck = checkCancelOriginPrincipal(entry.blobPath, origin, principal);
1772
- if (!principalCheck.ok) {
1773
- logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${principalCheck.reason}`;
1774
- return principalCheck;
1847
+ // Enforce the cancel-origin principal only for a NEWLY-ADDED blob.
1848
+ // A CancelRequested's origin-vs-signer rule is a write-time
1849
+ // authorization: it belongs to the commit that authors the event. A
1850
+ // later commit that merely carries the event forward -- e.g. the
1851
+ // run's own workflow-process cascade write of CancelPropagated /
1852
+ // RunCancelled, which re-lists the whole events prefix -- must not be
1853
+ // rejected because the carried-forward cancel was authored under a
1854
+ // different (supervisor) signer. Re-checking it protects nothing: the
1855
+ // byte-equality check above already proves a carried-forward blob is
1856
+ // unchanged, and the deletion-direction check proves it cannot be
1857
+ // dropped. A tampered (byte-diverged) blob never reaches here --
1858
+ // checkPriorByteEquality rejects it first. Mirrors the newly-terminal
1859
+ // gate below, which likewise acts only on a blob absent from the
1860
+ // prior tree.
1861
+ if ((await priorReadBlob(entry.blobPath)) === null) {
1862
+ const principalCheck = checkCancelOriginPrincipal(entry.blobPath, origin, principal);
1863
+ if (!principalCheck.ok) {
1864
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${principalCheck.reason}`;
1865
+ return principalCheck;
1866
+ }
1775
1867
  }
1776
1868
  }
1777
1869
  if (terminalSeq !== null) {
@@ -1792,9 +1884,14 @@ export const workflowRunKindHandler = {
1792
1884
  // signal, so a downstream consumer keyed on the signal does
1793
1885
  // not double-fire.
1794
1886
  if ((await priorReadBlob(entry.blobPath)) === null) {
1887
+ const status = TERMINAL_EVENT_STATUS.get(parsed.parsed.body.type);
1888
+ if (status === undefined) {
1889
+ throw new Error(`terminal event type ${parsed.parsed.body.type} has no workflow_run.status mapping`);
1890
+ }
1795
1891
  const terminalBytes = await readBlob(entry.blobPath);
1796
1892
  newlyTerminalRuns.push({
1797
1893
  runId,
1894
+ status,
1798
1895
  terminalEventJson: new TextDecoder().decode(terminalBytes),
1799
1896
  });
1800
1897
  }
@@ -1893,10 +1990,10 @@ export const workflowRunAuthorize = (principal, repoId, ref, action) => {
1893
1990
  reason: `workflow-process principal is malformed: ${parsed.summary}`,
1894
1991
  };
1895
1992
  }
1896
- if (parsed.deploymentId !== repoId.id) {
1993
+ if (parsed.anchorRunId !== repoId.id) {
1897
1994
  return {
1898
1995
  allowed: false,
1899
- reason: `workflow-process deployment ${parsed.deploymentId} cannot access workflow-run ${repoId.id}`,
1996
+ reason: `workflow-process deployment ${parsed.anchorRunId} cannot access workflow-run ${repoId.id}`,
1900
1997
  };
1901
1998
  }
1902
1999
  switch (action) {
@@ -1923,10 +2020,10 @@ export const workflowRunAuthorize = (principal, repoId, ref, action) => {
1923
2020
  reason: `supervisor principal is malformed: ${parsed.summary}`,
1924
2021
  };
1925
2022
  }
1926
- if (parsed.deploymentId !== repoId.id) {
2023
+ if (parsed.anchorRunId !== repoId.id) {
1927
2024
  return {
1928
2025
  allowed: false,
1929
- reason: `supervisor deployment ${parsed.deploymentId} cannot access workflow-run ${repoId.id}`,
2026
+ reason: `supervisor deployment ${parsed.anchorRunId} cannot access workflow-run ${repoId.id}`,
1930
2027
  };
1931
2028
  }
1932
2029
  switch (action) {
@@ -2032,6 +2129,10 @@ export const workflowRunAuthorize = (principal, repoId, ref, action) => {
2032
2129
  reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
2033
2130
  };
2034
2131
  }
2132
+ // Fail closed on any kind not handled above. The tenant-level
2133
+ // `workflow` principal kind (`@intx/types` principalKinds) is a
2134
+ // grant owner, not a workflow-run repo bearer, and never carries a
2135
+ // workflow-run push here -- so it is intentionally left denied.
2035
2136
  return {
2036
2137
  allowed: false,
2037
2138
  reason: `unknown principal kind: ${principal.kind}`,
@@ -2191,6 +2292,49 @@ function parseWatermark(bytes, watermarkFull) {
2191
2292
  }
2192
2293
  return validated.watermark;
2193
2294
  }
2295
+ /**
2296
+ * Internal signal thrown from the `enqueueInbox` merge callback when the
2297
+ * messageId is already present in a queue state. Caught at the `enqueueInbox`
2298
+ * boundary and turned into an `already-present` outcome; never escapes. It
2299
+ * carries the specific `reason` so the boundary maps it without re-deriving.
2300
+ */
2301
+ class InboxEntryAlreadyPresent extends Error {
2302
+ reason;
2303
+ constructor(reason, message) {
2304
+ super(message);
2305
+ this.reason = reason;
2306
+ this.name = "InboxEntryAlreadyPresent";
2307
+ }
2308
+ }
2309
+ /**
2310
+ * Thrown by `enqueueInbox` when the inbound's `receivedAt` is strictly below
2311
+ * the address's retention watermark. This is refusal under UNCERTAINTY, not
2312
+ * proof of prior receipt: the consumed dedup entry that would rule out a
2313
+ * duplicate may have been pruned, so the substrate can no longer tell a
2314
+ * duplicate from a never-processed message and refuses rather than risk
2315
+ * reprocessing. A caller gating a durable-receipt ack MUST NOT acknowledge on
2316
+ * this -- acking an "I cannot tell" would terminally drop a message that was
2317
+ * never written. It is its own type (not a generic Error) so that a caller,
2318
+ * and monitoring, can surface it as a distinct loud signal rather than
2319
+ * blending it into ordinary I/O-failure noise.
2320
+ *
2321
+ * Structurally unreachable on the mail-inbound path today: `enqueueInbox` is
2322
+ * only ever called with a freshly stamped `receivedAt` (a redelivery
2323
+ * re-stamps `Date.now()` rather than carrying the original), and the watermark
2324
+ * only ever advances to at most `consumedAt - retentionHorizonMs <= now`, so a
2325
+ * fresh `receivedAt` sits a full horizon above it. The sole path that carries
2326
+ * an original (old) `receivedAt` back into the queue is
2327
+ * `replayProcessingToInbox`, which writes straight to `inbox/` and bypasses
2328
+ * this gate entirely. If any redelivery source is ever changed to carry the
2329
+ * original `receivedAt` into `enqueueInbox`, this becomes reachable and its
2330
+ * withhold-not-ack handling becomes load-bearing.
2331
+ */
2332
+ export class StaleInboxEnqueueError extends Error {
2333
+ constructor(message) {
2334
+ super(message);
2335
+ this.name = "StaleInboxEnqueueError";
2336
+ }
2337
+ }
2194
2338
  /**
2195
2339
  * Append a new inbox entry for `address`. The merge callback reads
2196
2340
  * the address subtree under the per-repo lock, augments the inbox
@@ -2219,46 +2363,63 @@ export async function enqueueInbox(store, principal, repoId, args) {
2219
2363
  const inboxFname = `${inboxKey}.json`;
2220
2364
  const consumedFname = `${args.messageId}.json`;
2221
2365
  const messageIdSuffix = `-${args.messageId}.json`;
2222
- const { commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2223
- changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2224
- message: `enqueue inbox ${args.address} ${args.messageId}`,
2225
- computeDelta: async (_parentCommitSha, prior) => {
2226
- const listing = await readAddressListing(prior, addressSegment);
2227
- // Refuse a definitively-stale enqueue: a message whose receivedAt
2228
- // is strictly below the retention watermark could have had its
2229
- // consumed/ dedup entry pruned, so a duplicate can no longer be
2230
- // ruled out. Reject it LOUDLY rather than risk reprocessing. This
2231
- // is the second half of the exactly-once guarantee: above the
2232
- // watermark the consumed/ index is authoritative; below it, refuse.
2233
- if (args.receivedAt < listing.watermark) {
2234
- throw new Error(`claim_check_stale_enqueue: address ${args.address} message ${args.messageId} receivedAt ${String(args.receivedAt)} is below the retention watermark ${String(listing.watermark)}; its dedup entry may have been pruned, so it is refused as definitively-stale`);
2235
- }
2236
- if (listing.inbox.some((e) => e.name === inboxFname)) {
2237
- throw new Error(`claim_check_duplicate_inbox: ${newInboxPath} already exists`);
2238
- }
2239
- // consumed/ is keyed by messageId alone, so this is an exact
2240
- // filename lookup against the dedup index.
2241
- if (listing.consumed.some((e) => e.name === consumedFname)) {
2242
- throw new Error(`claim_check_already_consumed: address ${args.address} message ${args.messageId} is already in the consumed dedup index`);
2243
- }
2244
- if (listing.processing.some((e) => e.name.endsWith(messageIdSuffix))) {
2245
- throw new Error(`claim_check_already_processing: address ${args.address} message ${args.messageId} is currently in processing`);
2246
- }
2247
- // Reject a second inbox entry for the same messageId at a
2248
- // different receivedAt. The validatePush atomicity check also
2249
- // catches this on the commit path, but surfacing it here gives the
2250
- // caller a precise error and keeps the bad tree off the substrate.
2251
- const inboxDup = listing.inbox.find((e) => e.name.endsWith(messageIdSuffix));
2252
- if (inboxDup !== undefined) {
2253
- throw new Error(`claim_check_already_inbox: address ${args.address} message ${args.messageId} is already in the inbox at ${inboxPath(addressSegment, inboxDup.name.slice(0, -".json".length))}`);
2254
- }
2255
- return {
2256
- puts: { [newInboxPath]: utf8(JSON.stringify(envelope)) },
2257
- deletes: [],
2258
- };
2259
- },
2260
- });
2261
- return { commitSha, inboxKey, envelope };
2366
+ // The already-present cases throw `InboxEntryAlreadyPresent` from the merge
2367
+ // callback and are caught here into an `already-present` outcome; the
2368
+ // stale-refusal throws `StaleInboxEnqueueError`, and a substrate/I/O failure
2369
+ // throws a generic error -- both of those propagate. The return/throw split
2370
+ // is deliberate and load-bearing: it is the ack/withhold boundary for a
2371
+ // caller gating a durable-receipt ack (return = safe to ack, throw =
2372
+ // withhold), so `stale` sits with I/O on the throw side, NOT with the
2373
+ // already-present cases (see `StaleInboxEnqueueError`).
2374
+ let commitSha;
2375
+ try {
2376
+ ({ commitSha } = await store.writeTreeDelta(principal, repoId, ref, {
2377
+ changedPathPrefixes: new Set([addressPrefix(addressSegment)]),
2378
+ message: `enqueue inbox ${args.address} ${args.messageId}`,
2379
+ computeDelta: async (_parentCommitSha, prior) => {
2380
+ const listing = await readAddressListing(prior, addressSegment);
2381
+ // Refuse a definitively-stale enqueue: a message whose receivedAt
2382
+ // is strictly below the retention watermark could have had its
2383
+ // consumed/ dedup entry pruned, so a duplicate can no longer be
2384
+ // ruled out. Reject it LOUDLY rather than risk reprocessing. This
2385
+ // is the second half of the exactly-once guarantee: above the
2386
+ // watermark the consumed/ index is authoritative; below it, refuse.
2387
+ if (args.receivedAt < listing.watermark) {
2388
+ throw new StaleInboxEnqueueError(`claim_check_stale_enqueue: address ${args.address} message ${args.messageId} receivedAt ${String(args.receivedAt)} is below the retention watermark ${String(listing.watermark)}; its dedup entry may have been pruned, so it is refused as definitively-stale`);
2389
+ }
2390
+ if (listing.inbox.some((e) => e.name === inboxFname)) {
2391
+ throw new InboxEntryAlreadyPresent("duplicate", `claim_check_duplicate_inbox: ${newInboxPath} already exists`);
2392
+ }
2393
+ // consumed/ is keyed by messageId alone, so this is an exact
2394
+ // filename lookup against the dedup index.
2395
+ if (listing.consumed.some((e) => e.name === consumedFname)) {
2396
+ throw new InboxEntryAlreadyPresent("consumed", `claim_check_already_consumed: address ${args.address} message ${args.messageId} is already in the consumed dedup index`);
2397
+ }
2398
+ if (listing.processing.some((e) => e.name.endsWith(messageIdSuffix))) {
2399
+ throw new InboxEntryAlreadyPresent("processing", `claim_check_already_processing: address ${args.address} message ${args.messageId} is currently in processing`);
2400
+ }
2401
+ // Reject a second inbox entry for the same messageId at a
2402
+ // different receivedAt. The validatePush atomicity check also
2403
+ // catches this on the commit path, but surfacing it here gives the
2404
+ // caller a precise error and keeps the bad tree off the substrate.
2405
+ const inboxDup = listing.inbox.find((e) => e.name.endsWith(messageIdSuffix));
2406
+ if (inboxDup !== undefined) {
2407
+ throw new InboxEntryAlreadyPresent("already_inbox", `claim_check_already_inbox: address ${args.address} message ${args.messageId} is already in the inbox at ${inboxPath(addressSegment, inboxDup.name.slice(0, -".json".length))}`);
2408
+ }
2409
+ return {
2410
+ puts: { [newInboxPath]: utf8(JSON.stringify(envelope)) },
2411
+ deletes: [],
2412
+ };
2413
+ },
2414
+ }));
2415
+ }
2416
+ catch (err) {
2417
+ if (err instanceof InboxEntryAlreadyPresent) {
2418
+ return { outcome: "already-present", reason: err.reason };
2419
+ }
2420
+ throw err;
2421
+ }
2422
+ return { outcome: "enqueued", commitSha, inboxKey, envelope };
2262
2423
  }
2263
2424
  /**
2264
2425
  * Move the FIFO-first inbox entry for `address` to processing.
@@ -2346,7 +2507,7 @@ export async function dequeueToProcessing(store, principal, repoId, address) {
2346
2507
  * committed -- which is exactly when the supervisor forwards
2347
2508
  * `trigger.fired` -- observes the processing entry. Reading the working tree (rather than walking the
2348
2509
  * committed git tree) matches the workflow-process child's sibling
2349
- * reads of `workflow.json` and `runs/<runId>/events/`. Because the
2510
+ * read of `runs/<runId>/events/`. Because the
2350
2511
  * read issues no commit it cannot race the supervisor's `markConsumed`
2351
2512
  * write; it returns a point-in-time snapshot of the directory.
2352
2513
  */
@@ -2431,6 +2592,7 @@ export async function markConsumed(store, principal, repoId, args) {
2431
2592
  runId: args.runId,
2432
2593
  consumedAt: args.consumedAt,
2433
2594
  mailAuditRef: processingEnvelope.mailAuditRef,
2595
+ ...(args.rejection !== undefined ? { rejection: args.rejection } : {}),
2434
2596
  };
2435
2597
  consumedEnvelope = envelope;
2436
2598
  // The watermark may only advance, and never past the entry this
@@ -2574,6 +2736,114 @@ export async function readOwnedMessageIds(store, repoId) {
2574
2736
  }
2575
2737
  return owned;
2576
2738
  }
2739
+ /** Read one run's lifecycle from a committed workflow-run tree. */
2740
+ export async function readCommittedWorkflowRunLifecycle(reads, runId) {
2741
+ if (reads === null)
2742
+ return "absent";
2743
+ const runPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
2744
+ const runChildren = await reads.listDir(runPath);
2745
+ if (runChildren.some((entry) => entry.type === "blob" && entry.name === WORKFLOW_RUN_EVENTS_FILE)) {
2746
+ return "terminal";
2747
+ }
2748
+ const eventsPath = `${runPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
2749
+ const eventEntries = (await reads.listDir(eventsPath)).filter((entry) => entry.type === "blob" && parseEventSeq(entry.name) !== null);
2750
+ const latest = eventEntries.reduce((candidate, entry) => {
2751
+ if (candidate === undefined)
2752
+ return entry;
2753
+ const candidateSeq = parseEventSeq(candidate.name);
2754
+ const entrySeq = parseEventSeq(entry.name);
2755
+ return entrySeq !== null &&
2756
+ candidateSeq !== null &&
2757
+ entrySeq > candidateSeq
2758
+ ? entry
2759
+ : candidate;
2760
+ }, undefined);
2761
+ if (latest !== undefined) {
2762
+ const eventPath = `${eventsPath}/${latest.name}`;
2763
+ let parsed;
2764
+ try {
2765
+ parsed = JSON.parse(new TextDecoder().decode(await reads.readBlobByOid(latest.oid)));
2766
+ }
2767
+ catch (cause) {
2768
+ throw new Error(`workflow_run_event_unreadable: ${eventPath}`, { cause });
2769
+ }
2770
+ if (typeof parsed === "object" &&
2771
+ parsed !== null &&
2772
+ "type" in parsed &&
2773
+ typeof parsed.type === "string" &&
2774
+ TERMINAL_EVENT_TYPES.has(parsed.type)) {
2775
+ return "terminal";
2776
+ }
2777
+ }
2778
+ return eventEntries.length === 0 ? "absent" : "live";
2779
+ }
2780
+ /**
2781
+ * Read the durable lifecycle of one run from the workflow-run working tree.
2782
+ * `grants.json` alone is still an absent run: grants are staged before the
2783
+ * first trigger, while the first event is the durable proof that the run was
2784
+ * fired. A sealed event log is terminal by the kind handler's compaction
2785
+ * invariant.
2786
+ *
2787
+ * The supervisor uses this when in-memory cohort membership is empty. That
2788
+ * happens both for a genuinely new deployment and briefly during recovery,
2789
+ * so treating both states as "fire" would start a second driver for a live
2790
+ * log or reuse a terminal run.
2791
+ */
2792
+ export async function readWorkflowRunLifecycle(store, repoId, runId) {
2793
+ const fs = await import("node:fs/promises");
2794
+ const path = await import("node:path");
2795
+ const runDir = path.join(store.getRepoDir(repoId), WORKFLOW_RUN_RUNS_PREFIX, runId);
2796
+ try {
2797
+ await fs.access(path.join(runDir, WORKFLOW_RUN_EVENTS_FILE));
2798
+ return "terminal";
2799
+ }
2800
+ catch (cause) {
2801
+ if (!(cause instanceof Error) ||
2802
+ !("code" in cause) ||
2803
+ cause.code !== "ENOENT") {
2804
+ throw cause;
2805
+ }
2806
+ }
2807
+ const eventsDir = path.join(runDir, WORKFLOW_RUN_EVENTS_DIR);
2808
+ let files;
2809
+ try {
2810
+ files = await fs.readdir(eventsDir);
2811
+ }
2812
+ catch (cause) {
2813
+ if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
2814
+ return "absent";
2815
+ }
2816
+ throw cause;
2817
+ }
2818
+ const eventFiles = files.filter((file) => parseEventSeq(file) !== null);
2819
+ const latest = eventFiles.reduce((candidate, file) => {
2820
+ if (candidate === undefined)
2821
+ return file;
2822
+ const candidateSeq = parseEventSeq(candidate);
2823
+ const fileSeq = parseEventSeq(file);
2824
+ return fileSeq !== null && candidateSeq !== null && fileSeq > candidateSeq
2825
+ ? file
2826
+ : candidate;
2827
+ }, undefined);
2828
+ if (latest !== undefined) {
2829
+ const eventPath = path.join(eventsDir, latest);
2830
+ let parsed;
2831
+ try {
2832
+ parsed = JSON.parse(await fs.readFile(eventPath, "utf8"));
2833
+ }
2834
+ catch (cause) {
2835
+ throw new Error(`workflow_run_event_unreadable: ${eventPath}`, { cause });
2836
+ }
2837
+ if (typeof parsed === "object" &&
2838
+ parsed !== null &&
2839
+ "type" in parsed &&
2840
+ typeof parsed.type === "string" &&
2841
+ TERMINAL_EVENT_TYPES.has(parsed.type)) {
2842
+ return "terminal";
2843
+ }
2844
+ }
2845
+ return eventFiles.length === 0 ? "absent" : "live";
2846
+ }
2577
2847
  /**
2578
2848
  * Recovery path: move every processing entry at `address` back to
2579
2849
  * inbox preserving the original `<receivedAt>-<messageId>` filename
@@ -22,7 +22,7 @@ export type WorkflowRunEvent = {
22
22
  * path computation) with direct `isomorphic-git` tree/blob reads. It
23
23
  * never writes, so it carries no authorize gate of its own; callers
24
24
  * gate access at their own boundary (the REST routes use a
25
- * `workflow-run:<deploymentId>` grant check).
25
+ * `workflow-run:<runId>` grant check).
26
26
  */
27
27
  export interface WorkflowRunReader {
28
28
  /**
@@ -1,8 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import git from "isomorphic-git";
3
- import { WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
3
+ import { requireEventSeq, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
4
4
  import { WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, } from "./workflow-run-event-log.js";
5
- const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
6
5
  export function createWorkflowRunReader(repoStore) {
7
6
  function repoDirOrNull(repoId) {
8
7
  try {
@@ -106,12 +105,9 @@ export function createWorkflowRunReader(repoStore) {
106
105
  for (const entry of tree.tree) {
107
106
  if (entry.type !== "blob")
108
107
  continue;
109
- const m = EVENT_FILENAME_RE.exec(entry.path);
110
- if (m === null || m[1] === undefined)
111
- continue;
112
- const seq = Number.parseInt(m[1], 10);
113
- const blob = await git.readBlob({ fs, dir, oid: entry.oid });
114
108
  const path = `${eventsDir}/${entry.path}`;
109
+ const seq = requireEventSeq(entry.path, path);
110
+ const blob = await git.readBlob({ fs, dir, oid: entry.oid });
115
111
  const parsed = parseEventObject(new TextDecoder().decode(blob.blob), path);
116
112
  const type = parsed["type"];
117
113
  if (typeof type !== "string") {
@@ -0,0 +1,15 @@
1
+ import type { AgentRepoStore } from "./agent-repo.js";
2
+ import type { AllocatedSidecarTarget, SidecarAllocationRouter } from "./ws/sidecar-handler.js";
3
+ export declare const WORKFLOW_RUN_RESTORE_REFS: readonly ["refs/heads/main", "refs/heads/events"];
4
+ /**
5
+ * Replay every authoritative workflow-run ref the runtime understands onto an
6
+ * exact replacement allocation. Refs are sent sequentially and the function
7
+ * resolves only after the worker acknowledges each one, making it a barrier
8
+ * the deploy path can place before supervisor spawn.
9
+ */
10
+ export declare function restoreWorkflowRunToAllocation(args: {
11
+ agentRepoStore: AgentRepoStore;
12
+ allocationRouter: Pick<SidecarAllocationRouter, "sendWorkflowRunPackToAllocation">;
13
+ allocationTarget: AllocatedSidecarTarget;
14
+ agentAddress: string;
15
+ }): Promise<void>;