@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
@@ -165,8 +165,7 @@ import fs from "node:fs";
165
165
  import git from "isomorphic-git";
166
166
  import { type } from "arktype";
167
167
  import { getLogger } from "@intx/log";
168
- import { glob, repoActionToGrantVerb } from "@intx/hub-common";
169
- import { UserPrincipal, } from "./repo-store/index.js";
168
+ import { authorizeUserPrincipal, } from "./repo-store/index.js";
170
169
  import { WORKFLOW_RUN_EVENTS_FILE, splitCombinedEventLog, encodeCombinedEventLog, } from "./workflow-run-event-log.js";
171
170
  const logger = getLogger(["hub-sessions", "workflow-run-kind"]);
172
171
  export const WORKFLOW_RUN_GITIGNORE_PATH = ".gitignore";
@@ -179,6 +178,20 @@ export const WORKFLOW_RUN_CONTROL_PREFIX = "control";
179
178
  export const WORKFLOW_RUN_INBOX_DIR = "inbox";
180
179
  export const WORKFLOW_RUN_PROCESSING_DIR = "processing";
181
180
  export const WORKFLOW_RUN_CONSUMED_DIR = "consumed";
181
+ /**
182
+ * Per-run inbound mail-part subtree. Non-text inbound mail content
183
+ * (image/audio/video/document mail parts) is committed here as real
184
+ * files rather than inlined into the JSON event log, whose serialization
185
+ * boundary would corrupt binary bytes. The layout is
186
+ * `runs/<runId>/parts/<urlEncoded(messageId)>/<index>-<name>`: one
187
+ * directory per inbound message (so a long-lived run's successive turns
188
+ * never collide), and one file per mail part carrying its verbatim
189
+ * bytes. The workflow-host ingest writes the bytes and records a
190
+ * lightweight `{ name, contentType, ref }` reference into the run's
191
+ * trigger / signal payload; the step invoker reads the bytes back at
192
+ * `agent.send` time. Files are immutable once written, like `blobs/`.
193
+ */
194
+ export const WORKFLOW_RUN_PARTS_DIR = "parts";
182
195
  /**
183
196
  * Filename of the per-address retention watermark blob, a direct child
184
197
  * of `addresses/<urlEncoded>/` (a file, not a directory). Carries the
@@ -235,6 +248,38 @@ export const DEFAULT_CONSUMED_RETENTION_MS = 24 * 60 * 60 * 1000;
235
248
  * `<agentKey>` directory layer below the prefix).
236
249
  */
237
250
  export const WORKFLOW_RUN_AGENT_STATE_PREFIX = "agent-state";
251
+ /**
252
+ * Conversational-mailbox subtree for the warm single-step agent. The
253
+ * substrate mailbox backing commits the agent's durable inbox under
254
+ * `mailbox/INBOX/` so the full message history replicates to the hub
255
+ * alongside the run state. The layout is:
256
+ *
257
+ * - `mailbox/INBOX/index.json` — the mailbox index. MUTABLE: the
258
+ * backing rewrites it on every flush, so it is exempt from the
259
+ * retained-blob byte-equality walk the `<uid>.eml` blobs are subject
260
+ * to. It must nonetheless PERSIST once it existed: dropping it drops
261
+ * the whole mailbox and resets uidValidity/uidNext on the next open.
262
+ * - `mailbox/INBOX/<uid>.eml` — one message per file, carrying the
263
+ * raw signed message bytes. `<uid>` is a decimal integer >= 1. A
264
+ * RETAINED `<uid>.eml` (present in both prior and prospective) is
265
+ * opaque and IMMUTABLE: it must reappear byte-identically, exactly
266
+ * like `runs/<runId>/blobs/`. A prior `<uid>.eml` may be ABSENT from
267
+ * the prospective tree — that is the warm agent expunging a message.
268
+ * The raw bytes are not lost: they stay reachable through the parent
269
+ * commit, and a `workflow-run` repo's objects are never GC'd (its
270
+ * kind is off the GC allow-list), so an expunged message survives in
271
+ * history for the life of the run repo. The audit trail rests on
272
+ * "these objects are never pruned", not on the live tree being
273
+ * monotonic.
274
+ *
275
+ * The only entries permitted under `mailbox/` are the `INBOX/`
276
+ * directory; the only entries permitted under `mailbox/INBOX/` are
277
+ * `index.json` and `<uid>.eml` message files. Anything else fails the
278
+ * push.
279
+ */
280
+ export const WORKFLOW_RUN_MAILBOX_PREFIX = "mailbox";
281
+ export const WORKFLOW_RUN_MAILBOX_INBOX_DIR = "INBOX";
282
+ export const WORKFLOW_RUN_MAILBOX_INDEX_FILE = "index.json";
238
283
  /**
239
284
  * Allowed top-level entries in the prospective tree. Anything else
240
285
  * fails the push. `control/` has no v1 use and stays absent.
@@ -243,8 +288,16 @@ const ALLOWED_TOP_LEVEL = new Set([
243
288
  WORKFLOW_RUN_RUNS_PREFIX,
244
289
  WORKFLOW_RUN_ADDRESSES_PREFIX,
245
290
  WORKFLOW_RUN_AGENT_STATE_PREFIX,
291
+ WORKFLOW_RUN_MAILBOX_PREFIX,
246
292
  WORKFLOW_RUN_GITIGNORE_PATH,
247
293
  ]);
294
+ /**
295
+ * Per-message filename shape for the `mailbox/INBOX/` subtree:
296
+ * `<uid>.eml`, where `<uid>` is a decimal integer >= 1 (no leading zero,
297
+ * never `0`). Pins the shape so a malformed message name fails the push
298
+ * at the boundary rather than landing silently.
299
+ */
300
+ const MAILBOX_EML_FILENAME_RE = /^[1-9][0-9]*\.eml$/;
248
301
  const CLAIM_CHECK_SUBDIRS = new Set([
249
302
  WORKFLOW_RUN_INBOX_DIR,
250
303
  WORKFLOW_RUN_PROCESSING_DIR,
@@ -297,6 +350,30 @@ export function requireEventSeq(filename, context) {
297
350
  * silently.
298
351
  */
299
352
  const BLOB_FILENAME_RE = /^[0-9a-f]{64}$/;
353
+ /**
354
+ * Per-mail-part filename shape for the
355
+ * `runs/<runId>/parts/<urlEncoded(messageId)>/` subtree:
356
+ * `<index>-<name>`, where `index` is the decimal position of the
357
+ * mail part within its inbound message and `name` is a non-empty
358
+ * (possibly sanitized) filename. The workflow-host ingest owns the exact
359
+ * encoding and sanitizes untrusted names to satisfy this shape; this regex
360
+ * pins the shape so a malformed name fails the push rather than landing
361
+ * silently. The bytes themselves are opaque and immutable, exactly like
362
+ * `blobs/`.
363
+ */
364
+ const PART_FILENAME_RE = /^(0|[1-9][0-9]*)-(.+)$/;
365
+ /**
366
+ * Maximum byte length of a single mail part path component (the
367
+ * URL-encoded message segment, and each `<index>-<name>` filename).
368
+ * messageIds and mail part names arrive from untrusted inbound mail;
369
+ * an over-long RFC 5322 message-id URL-encodes past the filesystem's
370
+ * 255-byte component limit and would otherwise fail at disk-write time,
371
+ * downstream of validation. Reject it at the boundary instead.
372
+ */
373
+ export const MAX_MAIL_PART_PATH_COMPONENT_BYTES = 255;
374
+ function mailPartComponentByteLength(component) {
375
+ return new TextEncoder().encode(component).length;
376
+ }
300
377
  /**
301
378
  * Entries the kind handler accepts under `runs/<runId>/`. The `events/`
302
379
  * subtree carries the append-only event log; the `blobs/` subtree carries
@@ -315,6 +392,10 @@ const RUN_DIR_ALLOWED_CHILDREN = new Set([
315
392
  // files into one combined file by a compaction commit.
316
393
  WORKFLOW_RUN_EVENTS_FILE,
317
394
  WORKFLOW_RUN_GRANTS_FILE,
395
+ // Inbound-mail mail part bytes committed as real files. See
396
+ // WORKFLOW_RUN_PARTS_DIR; validated by enumerateRunParts and
397
+ // held immutable by the same prior-tree byte-equality walk as blobs.
398
+ WORKFLOW_RUN_PARTS_DIR,
318
399
  ]);
319
400
  /**
320
401
  * Filename shape for inbox and processing entries:
@@ -390,22 +471,6 @@ const WatermarkEnvelope = type({
390
471
  watermark: "number >= 0",
391
472
  "+": "ignore",
392
473
  });
393
- /**
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.
398
- *
399
- * This map is a hand-rolled copy of the runtime's terminal-run vocabulary
400
- * (`isTerminalRunPhase` in `@intx/workflow` state-machine `state.ts`,
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.
406
- * Drift silently reopens the restore-time double-driver collision that
407
- * `readOwnedMessageIds` (below) exists to prevent.
408
- */
409
474
  const TERMINAL_EVENT_STATUS = new Map([
410
475
  ["RunCompleted", "completed"],
411
476
  ["RunFailed", "failed"],
@@ -417,6 +482,27 @@ const TERMINAL_EVENT_STATUS = new Map([
417
482
  * the two cannot drift apart.
418
483
  */
419
484
  const TERMINAL_EVENT_TYPES = new Set(TERMINAL_EVENT_STATUS.keys());
485
+ /**
486
+ * Classify a workflow-run event type against the terminal-status vocabulary.
487
+ * `TERMINAL_EVENT_STATUS` is the sole authority (see above), so a type absent
488
+ * from it is by definition not terminal: no separate membership set is
489
+ * consulted and no "unmapped terminal type" case can arise.
490
+ */
491
+ export function classifyTerminalEvent(eventType) {
492
+ const status = TERMINAL_EVENT_STATUS.get(eventType);
493
+ return status === undefined
494
+ ? { terminal: false }
495
+ : { terminal: true, status };
496
+ }
497
+ /**
498
+ * True when a blob is absent from the prior tree -- i.e. this commit is the
499
+ * one that authored it. Consumers act only on a newly-added blob; a blob
500
+ * already present in the prior tree was carried forward unchanged by a
501
+ * compaction commit and must not be re-acted upon.
502
+ */
503
+ async function blobIsNewlyAdded(blobPath, priorReadBlob) {
504
+ return (await priorReadBlob(blobPath)) === null;
505
+ }
420
506
  /**
421
507
  * Recognised CancelRequested origins. Mirrors the workflow package's
422
508
  * `CANCEL_ORIGINS` vocabulary; inlined here so the substrate does
@@ -550,7 +636,7 @@ async function enumerateEventBlobs(listDir, scopeRunIds) {
550
636
  if (offender !== undefined) {
551
637
  return {
552
638
  ok: false,
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`,
639
+ reason: `run directory ${runDirPath} contains unexpected entry ${JSON.stringify(offender)}; only "${WORKFLOW_RUN_EVENTS_DIR}", "${WORKFLOW_RUN_BLOBS_DIR}", "${WORKFLOW_RUN_EVENTS_FILE}", "${WORKFLOW_RUN_GRANTS_FILE}", and "${WORKFLOW_RUN_PARTS_DIR}" are allowed`,
554
640
  };
555
641
  }
556
642
  const hasCombined = runChildren.includes(WORKFLOW_RUN_EVENTS_FILE);
@@ -566,14 +652,16 @@ async function enumerateEventBlobs(listDir, scopeRunIds) {
566
652
  if (hasCombined)
567
653
  continue;
568
654
  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) {
655
+ // The pre-first-event window: `grants.json` (the hub's `run.grants`
656
+ // frame writes grants ahead of the trigger) and `parts/` (the
657
+ // supervisor commits inbound-mail mail part bytes before firing the
658
+ // trigger) may both land before the child emits its first event. Carry
659
+ // such a run forward untouched -- there is no event log to enumerate
660
+ // yet, and the mail parts subtree is validated by its own walk. Any
661
+ // other events-less shape (e.g. a bare `blobs/` with no events) remains
662
+ // rejected below.
663
+ const nonPreEvent = runChildren.filter((c) => c !== WORKFLOW_RUN_GRANTS_FILE && c !== WORKFLOW_RUN_PARTS_DIR);
664
+ if (nonPreEvent.length === 0) {
577
665
  continue;
578
666
  }
579
667
  return {
@@ -814,6 +902,159 @@ async function enumerateRunBlobs(listDir, scopeRunIds) {
814
902
  }
815
903
  return { ok: true, blobs: out };
816
904
  }
905
+ /**
906
+ * Walk every `runs/<runId>/parts/<messageSegment>/` directory and
907
+ * validate each entry: the `<messageSegment>` is a URL-encoded messageId
908
+ * that must round-trip cleanly (the same canonical-encoding discipline the
909
+ * claim-check `addresses/` subtree enforces) and stay within the path-
910
+ * component byte cap, must be a directory rather than a dangling blob, and
911
+ * each filename matches the `<index>-<name>` shape within the same cap. The
912
+ * `parts/` subdirectory is optional: a run that never received a
913
+ * non-text inbound message never produces one. Returns the flat entry list
914
+ * so the caller can apply immutability checks against the prior tree,
915
+ * exactly as it does for blobs.
916
+ */
917
+ async function enumerateRunParts(listDir, scopeRunIds) {
918
+ const out = [];
919
+ // See enumerateRunBlobs: a defined `scopeRunIds` walks only the commit's
920
+ // touched runs; an untouched run's mail parts are carried forward
921
+ // byte-identical and were validated when written.
922
+ const runIds = scopeRunIds === undefined
923
+ ? await listDir(WORKFLOW_RUN_RUNS_PREFIX)
924
+ : Array.from(scopeRunIds);
925
+ for (const runId of runIds) {
926
+ const runDirPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
927
+ const runChildren = await listDir(runDirPath);
928
+ if (!runChildren.includes(WORKFLOW_RUN_PARTS_DIR))
929
+ continue;
930
+ const partsDirPath = `${runDirPath}/${WORKFLOW_RUN_PARTS_DIR}`;
931
+ const messageSegments = await listDir(partsDirPath);
932
+ for (const messageSegment of messageSegments) {
933
+ const roundTrip = checkUrlSegmentRoundTrip(messageSegment);
934
+ if (!roundTrip.ok) {
935
+ return {
936
+ ok: false,
937
+ reason: `mail part message ${roundTrip.reason} under ${partsDirPath}`,
938
+ };
939
+ }
940
+ if (mailPartComponentByteLength(messageSegment) >
941
+ MAX_MAIL_PART_PATH_COMPONENT_BYTES) {
942
+ return {
943
+ ok: false,
944
+ reason: `mail part message segment ${JSON.stringify(messageSegment)} under ${partsDirPath} exceeds the ${String(MAX_MAIL_PART_PATH_COMPONENT_BYTES)}-byte path-component limit`,
945
+ };
946
+ }
947
+ const messageDirPath = `${partsDirPath}/${messageSegment}`;
948
+ const filenames = await listDir(messageDirPath);
949
+ // A message segment must be a directory carrying at least one
950
+ // mail part file. An empty listing means either a dangling blob
951
+ // committed directly at `parts/<segment>` (the substrate lists a
952
+ // blob path as empty, exactly as the agent-state walk detects) or an
953
+ // empty directory; both are rejected so untrusted inbound content has
954
+ // no silent-accept path.
955
+ if (filenames.length === 0) {
956
+ return {
957
+ ok: false,
958
+ reason: `mail part message segment ${JSON.stringify(messageSegment)} under ${partsDirPath} is not a directory carrying mail part files`,
959
+ };
960
+ }
961
+ for (const filename of filenames) {
962
+ if (!PART_FILENAME_RE.test(filename)) {
963
+ return {
964
+ ok: false,
965
+ reason: `mail part filename ${messageDirPath}/${filename} does not match <index>-<name>`,
966
+ };
967
+ }
968
+ if (mailPartComponentByteLength(filename) >
969
+ MAX_MAIL_PART_PATH_COMPONENT_BYTES) {
970
+ return {
971
+ ok: false,
972
+ reason: `mail part filename ${messageDirPath}/${filename} exceeds the ${String(MAX_MAIL_PART_PATH_COMPONENT_BYTES)}-byte path-component limit`,
973
+ };
974
+ }
975
+ // Each mail part entry must be a leaf blob, not a nested directory.
976
+ // The `<index>-<name>` shape is permissive (`.+`), so a directory
977
+ // named e.g. `0-foo` would otherwise pass and admit an arbitrarily
978
+ // nested subtree, breaking the one-file-per-mail-part invariant and
979
+ // -- on a pack-receive validation with no `listDirOids` -- driving the
980
+ // immutability resolver to `readBlob` a tree path and throw. A blob
981
+ // lists as empty; a directory lists its children.
982
+ const filePath = `${messageDirPath}/${filename}`;
983
+ if ((await listDir(filePath)).length > 0) {
984
+ return {
985
+ ok: false,
986
+ reason: `mail part ${filePath} is a directory; each mail part must be a single file`,
987
+ };
988
+ }
989
+ out.push({
990
+ runId,
991
+ messageSegment,
992
+ filename,
993
+ blobPath: filePath,
994
+ });
995
+ }
996
+ }
997
+ }
998
+ return { ok: true, parts: out };
999
+ }
1000
+ /**
1001
+ * Validate the per-run `parts/` subtree's immutability against the
1002
+ * prior tree, both directions, mirroring the blobs walk. Mail part files
1003
+ * are write-once. A path present in the prior tree must carry the same git
1004
+ * blob OID in the prospective tree: unlike `blobs/`, a mail part filename
1005
+ * is `<index>-<name>` (not a content hash), so the same path can carry
1006
+ * different bytes -- the OID compare is the load-bearing immutability guard,
1007
+ * and comparing the OID git already computed avoids re-reading tens of MB of
1008
+ * mail part bytes on every commit that merely touches the run. A prior path
1009
+ * must reappear (no deletion): run reclaim drops the whole `runs/<runId>/`
1010
+ * subtree outside `validatePush`, so a partial mail part deletion is always
1011
+ * a violation. Structural shape is enforced by `enumerateRunParts`.
1012
+ */
1013
+ async function validateRunPartsSubtree(args) {
1014
+ const prospective = await enumerateRunParts(args.listDir, args.scopeRunIds);
1015
+ if (!prospective.ok)
1016
+ return prospective;
1017
+ const prior = await enumerateRunParts(args.priorListDir, args.scopeRunIds);
1018
+ if (!prior.ok) {
1019
+ return {
1020
+ ok: false,
1021
+ reason: `prior tree's mail parts subtree is structurally invalid: ${prior.reason}`,
1022
+ };
1023
+ }
1024
+ const prospectiveOid = makeListingOidResolver("prospective", args.listDirOids, async (p) => (await git.hashBlob({ object: await args.readBlob(p) })).oid);
1025
+ const priorOid = makeListingOidResolver("prior", args.priorListDirOids, async (p) => {
1026
+ const bytes = await args.priorReadBlob(p);
1027
+ if (bytes === null) {
1028
+ throw new Error(`mail parts: prior entry ${p} was enumerated but its bytes could not be read`);
1029
+ }
1030
+ return (await git.hashBlob({ object: bytes })).oid;
1031
+ });
1032
+ const prospectivePaths = new Set(prospective.parts.map((e) => e.blobPath));
1033
+ const priorPaths = new Set(prior.parts.map((e) => e.blobPath));
1034
+ for (const entry of prospective.parts) {
1035
+ if (!priorPaths.has(entry.blobPath))
1036
+ continue; // newly added
1037
+ const [next, before] = await Promise.all([
1038
+ prospectiveOid(entry.blobPath),
1039
+ priorOid(entry.blobPath),
1040
+ ]);
1041
+ if (next !== before) {
1042
+ return {
1043
+ ok: false,
1044
+ reason: `mail part ${entry.blobPath} bytes diverge from the prior tree (blob OID ${next} vs ${before}); mail part files are immutable once written`,
1045
+ };
1046
+ }
1047
+ }
1048
+ for (const entry of prior.parts) {
1049
+ if (prospectivePaths.has(entry.blobPath))
1050
+ continue;
1051
+ return {
1052
+ ok: false,
1053
+ reason: `mail part ${entry.blobPath} present in the prior tree is missing from the prospective tree; mail part files are immutable once written`,
1054
+ };
1055
+ }
1056
+ return { ok: true };
1057
+ }
817
1058
  /**
818
1059
  * Enforce blob immutability via prior-tree byte equality. The blob
819
1060
  * value itself is opaque bytes (no JSON envelope, no arktype
@@ -925,13 +1166,16 @@ async function checkPriorByteEquality(blobPath, readBlob, priorReadBlob) {
925
1166
  return { ok: true };
926
1167
  }
927
1168
  /**
928
- * Round-trip an `<urlEncoded(address)>` segment through decode then
929
- * encode. A divergence means the segment is not the canonical
930
- * encoding of any address, which would leave consumers guessing
931
- * which encoding to use when reading the subtree. Surface as a
932
- * concrete rejection at push time.
1169
+ * Round-trip a URL-encoded path segment through decode then encode. A
1170
+ * divergence means the segment is not the canonical encoding of any
1171
+ * value, which would leave consumers guessing which encoding to use
1172
+ * when reading the subtree. Surface as a concrete rejection at push
1173
+ * time. Shared by every subtree that keys a directory by an
1174
+ * `encodeURIComponent`-encoded identity (`addresses/`, `agent-state/`,
1175
+ * and the per-run `parts/` message segment); callers prepend
1176
+ * their own subtree context to the returned reason.
933
1177
  */
934
- function checkAddressSegmentRoundTrip(segment) {
1178
+ function checkUrlSegmentRoundTrip(segment) {
935
1179
  let decoded;
936
1180
  try {
937
1181
  decoded = decodeURIComponent(segment);
@@ -939,14 +1183,14 @@ function checkAddressSegmentRoundTrip(segment) {
939
1183
  catch (cause) {
940
1184
  return {
941
1185
  ok: false,
942
- reason: `address segment ${JSON.stringify(segment)} is not a valid URL-encoded string: ${cause instanceof Error ? cause.message : String(cause)}`,
1186
+ reason: `segment ${JSON.stringify(segment)} is not a valid URL-encoded string: ${cause instanceof Error ? cause.message : String(cause)}`,
943
1187
  };
944
1188
  }
945
1189
  const reencoded = encodeURIComponent(decoded);
946
1190
  if (reencoded !== segment) {
947
1191
  return {
948
1192
  ok: false,
949
- reason: `address segment ${JSON.stringify(segment)} does not round-trip URL-encoding (re-encoded as ${JSON.stringify(reencoded)})`,
1193
+ reason: `segment ${JSON.stringify(segment)} does not round-trip URL-encoding (re-encoded as ${JSON.stringify(reencoded)})`,
950
1194
  };
951
1195
  }
952
1196
  return { ok: true, decoded };
@@ -979,9 +1223,10 @@ async function enumerateClaimCheckBlobs(listDir, resolveConsumedOid) {
979
1223
  const perAddress = new Map();
980
1224
  const segments = await listDir(WORKFLOW_RUN_ADDRESSES_PREFIX);
981
1225
  for (const segment of segments) {
982
- const roundTrip = checkAddressSegmentRoundTrip(segment);
983
- if (!roundTrip.ok)
984
- return roundTrip;
1226
+ const roundTrip = checkUrlSegmentRoundTrip(segment);
1227
+ if (!roundTrip.ok) {
1228
+ return { ok: false, reason: `address ${roundTrip.reason}` };
1229
+ }
985
1230
  const addrDir = `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${segment}`;
986
1231
  const children = await listDir(addrDir);
987
1232
  for (const child of children) {
@@ -1695,11 +1940,11 @@ async function validateAgentStateSubtree(topLevelTreePaths, listDir) {
1695
1940
  }
1696
1941
  const segments = await listDir(WORKFLOW_RUN_AGENT_STATE_PREFIX);
1697
1942
  for (const segment of segments) {
1698
- const roundTrip = checkAddressSegmentRoundTrip(segment);
1943
+ const roundTrip = checkUrlSegmentRoundTrip(segment);
1699
1944
  if (!roundTrip.ok) {
1700
1945
  return {
1701
1946
  ok: false,
1702
- reason: `agent-state segment ${JSON.stringify(segment)} does not round-trip URL-encoding; ${roundTrip.reason}`,
1947
+ reason: `agent-state ${roundTrip.reason}`,
1703
1948
  };
1704
1949
  }
1705
1950
  // Reject a blob dangling directly at `agent-state/<segment>`: every
@@ -1716,6 +1961,149 @@ async function validateAgentStateSubtree(topLevelTreePaths, listDir) {
1716
1961
  }
1717
1962
  return { ok: true };
1718
1963
  }
1964
+ /**
1965
+ * Enforce mailbox `<uid>.eml` immutability via prior-tree byte equality.
1966
+ * A message blob RETAINED from the prior tree (present in both) must carry
1967
+ * byte-identical contents in the prospective tree. A prior blob absent
1968
+ * from the prospective tree is a legal expunge and never reaches here
1969
+ * (the caller only checks retained paths). Mirrors the blob-immutability
1970
+ * discipline (`checkBlobPriorByteEquality`) with mailbox-specific wording.
1971
+ */
1972
+ async function checkMailboxEmlPriorByteEquality(emlPath, readBlob, priorReadBlob) {
1973
+ const prior = await priorReadBlob(emlPath);
1974
+ if (prior === null)
1975
+ return { ok: true };
1976
+ const prospective = await readBlob(emlPath);
1977
+ if (prior.byteLength !== prospective.byteLength) {
1978
+ return {
1979
+ ok: false,
1980
+ reason: `mailbox message ${emlPath} bytes diverge from the prior tree (lengths ${String(prior.byteLength)} vs ${String(prospective.byteLength)}); a retained mailbox message is immutable`,
1981
+ };
1982
+ }
1983
+ for (let i = 0; i < prior.byteLength; i++) {
1984
+ if (prior[i] !== prospective[i]) {
1985
+ return {
1986
+ ok: false,
1987
+ reason: `mailbox message ${emlPath} bytes diverge from the prior tree at offset ${String(i)}; a retained mailbox message is immutable`,
1988
+ };
1989
+ }
1990
+ }
1991
+ return { ok: true };
1992
+ }
1993
+ /**
1994
+ * Walk the `mailbox/INBOX/` subtree and validate its shape. The only
1995
+ * entry permitted directly under `mailbox/` is the `INBOX/` directory;
1996
+ * the only entries permitted under `mailbox/INBOX/` are the mutable
1997
+ * `index.json` file and `<uid>.eml` message files. Each entry must be a
1998
+ * leaf blob rather than a nested directory (a blob lists as empty, a
1999
+ * directory lists its children -- the same discrimination the agent-state
2000
+ * and mail-parts walks use). Returns the flat set of `<uid>.eml` blob
2001
+ * paths so the caller can hold retained messages immutable against the
2002
+ * prior tree, plus `indexPresent` -- whether `index.json` exists under
2003
+ * the INBOX -- so the caller can enforce index continuity. An absent
2004
+ * `mailbox/` subtree lists as empty and contributes no message paths.
2005
+ */
2006
+ async function enumerateMailboxInbox(listDir) {
2007
+ const emlPaths = new Set();
2008
+ let indexPresent = false;
2009
+ const mailboxChildren = await listDir(WORKFLOW_RUN_MAILBOX_PREFIX);
2010
+ if (mailboxChildren.length === 0)
2011
+ return { ok: true, emlPaths, indexPresent };
2012
+ for (const child of mailboxChildren) {
2013
+ if (child !== WORKFLOW_RUN_MAILBOX_INBOX_DIR) {
2014
+ return {
2015
+ ok: false,
2016
+ reason: `mailbox subtree contains unexpected entry ${JSON.stringify(child)} under ${WORKFLOW_RUN_MAILBOX_PREFIX}/; only "${WORKFLOW_RUN_MAILBOX_INBOX_DIR}" is allowed`,
2017
+ };
2018
+ }
2019
+ }
2020
+ const inboxPath = `${WORKFLOW_RUN_MAILBOX_PREFIX}/${WORKFLOW_RUN_MAILBOX_INBOX_DIR}`;
2021
+ const inboxEntries = await listDir(inboxPath);
2022
+ // A `mailbox/` top-level whose `INBOX` child carries no entries is a
2023
+ // dangling blob committed directly at `mailbox/INBOX` (a blob lists as
2024
+ // empty), not the required directory. Git never records an empty
2025
+ // directory, so a present-but-empty listing is always the blob case.
2026
+ if (inboxEntries.length === 0) {
2027
+ return {
2028
+ ok: false,
2029
+ reason: `mailbox ${inboxPath} is a blob, not a directory carrying "${WORKFLOW_RUN_MAILBOX_INDEX_FILE}" and <uid>.eml message files`,
2030
+ };
2031
+ }
2032
+ for (const entry of inboxEntries) {
2033
+ const entryPath = `${inboxPath}/${entry}`;
2034
+ if (entry === WORKFLOW_RUN_MAILBOX_INDEX_FILE) {
2035
+ if ((await listDir(entryPath)).length > 0) {
2036
+ return {
2037
+ ok: false,
2038
+ reason: `mailbox ${entryPath} is a directory; the mailbox index must be a single file`,
2039
+ };
2040
+ }
2041
+ indexPresent = true;
2042
+ continue;
2043
+ }
2044
+ if (!MAILBOX_EML_FILENAME_RE.test(entry)) {
2045
+ return {
2046
+ ok: false,
2047
+ reason: `mailbox entry ${entryPath} does not match "${WORKFLOW_RUN_MAILBOX_INDEX_FILE}" or <uid>.eml (uid a decimal integer >= 1)`,
2048
+ };
2049
+ }
2050
+ if ((await listDir(entryPath)).length > 0) {
2051
+ return {
2052
+ ok: false,
2053
+ reason: `mailbox message ${entryPath} is a directory; each message must be a single .eml file`,
2054
+ };
2055
+ }
2056
+ emlPaths.add(entryPath);
2057
+ }
2058
+ return { ok: true, emlPaths, indexPresent };
2059
+ }
2060
+ /**
2061
+ * Validate the `mailbox/INBOX/` subtree (design conversational-mailbox).
2062
+ * Enforces the subtree shape via `enumerateMailboxInbox`, then holds a
2063
+ * RETAINED `<uid>.eml` message blob byte-identical against the prior tree.
2064
+ * A prior `<uid>.eml` absent from the prospective tree is a legal expunge:
2065
+ * the warm agent physically removes a message from the live INBOX. The raw
2066
+ * bytes are not lost -- they stay reachable through the parent commit, and
2067
+ * a `workflow-run` repo's objects are never GC'd (its kind is excluded
2068
+ * from the GC allow-list; see `DEFAULT_GC_KINDS` in `agent-repo`), so the
2069
+ * expunged message survives in history for the life of the run repo. The
2070
+ * audit trail therefore rests on "these objects are never pruned", not on
2071
+ * the live tree being monotonic.
2072
+ *
2073
+ * The `index.json` entry is mutable, but must PERSIST once it existed: if
2074
+ * the prior tree carried an index and the prospective tree drops it, the
2075
+ * whole mailbox has vanished, which would reset `uidValidity` / `uidNext`
2076
+ * on the next open and force uid reuse from 1. That is rejected. A
2077
+ * well-behaved backing always rewrites `index.json` on flush, so the guard
2078
+ * fails no legitimate push. Mirrors the blob-immutability discipline the
2079
+ * `runs/<runId>/blobs/` subtree uses, minus the deletion direction.
2080
+ */
2081
+ async function validateMailboxSubtree(listDir, readBlob, priorListDir, priorReadBlob) {
2082
+ const prospective = await enumerateMailboxInbox(listDir);
2083
+ if (!prospective.ok)
2084
+ return prospective;
2085
+ const prior = await enumerateMailboxInbox(priorListDir);
2086
+ if (!prior.ok) {
2087
+ return {
2088
+ ok: false,
2089
+ reason: `prior tree's mailbox subtree is structurally invalid: ${prior.reason}`,
2090
+ };
2091
+ }
2092
+ for (const emlPath of prospective.emlPaths) {
2093
+ if (!prior.emlPaths.has(emlPath))
2094
+ continue; // newly added
2095
+ const immutable = await checkMailboxEmlPriorByteEquality(emlPath, readBlob, priorReadBlob);
2096
+ if (!immutable.ok)
2097
+ return immutable;
2098
+ }
2099
+ if (prior.indexPresent && !prospective.indexPresent) {
2100
+ return {
2101
+ ok: false,
2102
+ reason: `mailbox ${WORKFLOW_RUN_MAILBOX_PREFIX}/${WORKFLOW_RUN_MAILBOX_INBOX_DIR}/${WORKFLOW_RUN_MAILBOX_INDEX_FILE} present in the prior tree is missing from the prospective tree; the mailbox index must persist so uidValidity/uidNext continuity holds across reopens`,
2103
+ };
2104
+ }
2105
+ return { ok: true };
2106
+ }
1719
2107
  export const workflowRunKindHandler = {
1720
2108
  kind: "workflow-run",
1721
2109
  directoryPrefix: "workflow-runs",
@@ -1745,7 +2133,7 @@ export const workflowRunKindHandler = {
1745
2133
  if (!ALLOWED_TOP_LEVEL.has(entry)) {
1746
2134
  return {
1747
2135
  ok: false,
1748
- reason: `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_RUN_RUNS_PREFIX}", "${WORKFLOW_RUN_ADDRESSES_PREFIX}", "${WORKFLOW_RUN_AGENT_STATE_PREFIX}", "${WORKFLOW_RUN_GITIGNORE_PATH}"`,
2136
+ reason: `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_RUN_RUNS_PREFIX}", "${WORKFLOW_RUN_ADDRESSES_PREFIX}", "${WORKFLOW_RUN_AGENT_STATE_PREFIX}", "${WORKFLOW_RUN_MAILBOX_PREFIX}", "${WORKFLOW_RUN_GITIGNORE_PATH}"`,
1749
2137
  };
1750
2138
  }
1751
2139
  }
@@ -1774,6 +2162,21 @@ export const workflowRunKindHandler = {
1774
2162
  return claimCheck;
1775
2163
  }
1776
2164
  }
2165
+ const mailboxPresent = topLevelTreePaths.includes(WORKFLOW_RUN_MAILBOX_PREFIX) ||
2166
+ priorTopLevels.includes(WORKFLOW_RUN_MAILBOX_PREFIX);
2167
+ if (mailboxPresent) {
2168
+ // Enter mailbox validation when the prospective OR prior tree
2169
+ // carries a `mailbox/` subtree. A prospective tree that drops the
2170
+ // subtree while the prior tree held one must still go through the
2171
+ // walk so the index-continuity guard fires on the vanished
2172
+ // `index.json` (a message-only expunge is legal; dropping the whole
2173
+ // mailbox is not).
2174
+ const mailboxCheck = await validateMailboxSubtree(listDir, readBlob, priorListDir, priorReadBlob);
2175
+ if (!mailboxCheck.ok) {
2176
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${mailboxCheck.reason}`;
2177
+ return mailboxCheck;
2178
+ }
2179
+ }
1777
2180
  const runsPresent = topLevelTreePaths.includes(WORKFLOW_RUN_RUNS_PREFIX) ||
1778
2181
  priorTopLevels.includes(WORKFLOW_RUN_RUNS_PREFIX);
1779
2182
  if (!runsPresent) {
@@ -1858,7 +2261,7 @@ export const workflowRunKindHandler = {
1858
2261
  // checkPriorByteEquality rejects it first. Mirrors the newly-terminal
1859
2262
  // gate below, which likewise acts only on a blob absent from the
1860
2263
  // prior tree.
1861
- if ((await priorReadBlob(entry.blobPath)) === null) {
2264
+ if (await blobIsNewlyAdded(entry.blobPath, priorReadBlob)) {
1862
2265
  const principalCheck = checkCancelOriginPrincipal(entry.blobPath, origin, principal);
1863
2266
  if (!principalCheck.ok) {
1864
2267
  logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${principalCheck.reason}`;
@@ -1872,7 +2275,8 @@ export const workflowRunKindHandler = {
1872
2275
  reason: `run ${runId} has event at seq ${String(entry.filenameSeq)} after terminal ${terminalType} at seq ${String(terminalSeq)}`,
1873
2276
  };
1874
2277
  }
1875
- if (TERMINAL_EVENT_TYPES.has(parsed.parsed.body.type)) {
2278
+ const classified = classifyTerminalEvent(parsed.parsed.body.type);
2279
+ if (classified.terminal) {
1876
2280
  terminalSeq = entry.filenameSeq;
1877
2281
  terminalType = parsed.parsed.body.type;
1878
2282
  // Surface the run as newly terminal only when this commit is
@@ -1883,15 +2287,11 @@ export const workflowRunKindHandler = {
1883
2287
  // terminal blob already present in the prior tree and emits no
1884
2288
  // signal, so a downstream consumer keyed on the signal does
1885
2289
  // not double-fire.
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
- }
2290
+ if (await blobIsNewlyAdded(entry.blobPath, priorReadBlob)) {
1891
2291
  const terminalBytes = await readBlob(entry.blobPath);
1892
2292
  newlyTerminalRuns.push({
1893
2293
  runId,
1894
- status,
2294
+ status: classified.status,
1895
2295
  terminalEventJson: new TextDecoder().decode(terminalBytes),
1896
2296
  });
1897
2297
  }
@@ -1965,6 +2365,19 @@ export const workflowRunKindHandler = {
1965
2365
  reason: `blob ${b.blobPath} present in the prior tree is missing from the prospective tree; blob entries are immutable once written`,
1966
2366
  };
1967
2367
  }
2368
+ const partsCheck = await validateRunPartsSubtree({
2369
+ listDir,
2370
+ priorListDir,
2371
+ readBlob,
2372
+ priorReadBlob,
2373
+ listDirOids,
2374
+ priorListDirOids,
2375
+ scopeRunIds,
2376
+ });
2377
+ if (!partsCheck.ok) {
2378
+ logger.debug `workflow-run validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${partsCheck.reason}`;
2379
+ return partsCheck;
2380
+ }
1968
2381
  return { ok: true, newlyTerminalRuns };
1969
2382
  },
1970
2383
  onRefUpdated() {
@@ -2071,63 +2484,13 @@ export const workflowRunAuthorize = (principal, repoId, ref, action) => {
2071
2484
  }
2072
2485
  }
2073
2486
  if (principal.kind === "user") {
2074
- // The route layer has already pre-resolved the grant verdict and
2075
- // attached it as `authz`. The substrate does NOT re-query the
2076
- // grant store here; it (a) checks the bearer-token's claims
2077
- // bound the requested (ref, action) and have not expired, and
2078
- // (b) sanity-checks that the pre-resolved verdict targets this
2079
- // exact resource and grant verb. Both gates must pass before the
2080
- // verdict's `effect` is honoured.
2081
- const parsed = UserPrincipal(principal);
2082
- if (parsed instanceof type.errors) {
2083
- return {
2084
- allowed: false,
2085
- reason: `user principal is malformed: ${parsed.summary}`,
2086
- };
2087
- }
2088
- if (!parsed.tokenClaims.actions.includes(action)) {
2089
- return {
2090
- allowed: false,
2091
- reason: `token does not grant action ${action}`,
2092
- };
2093
- }
2094
- // `ref === "*"` is the substrate's sentinel for the bulk read
2095
- // performed by `listRefs`. Per-ref filtering is the advertise-refs
2096
- // layer's responsibility, so the bulk read is gated on action and
2097
- // expiry alone.
2098
- if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
2099
- return {
2100
- allowed: false,
2101
- reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
2102
- };
2103
- }
2104
- if (Date.now() >= parsed.tokenClaims.expiresAt) {
2105
- return {
2106
- allowed: false,
2107
- reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
2108
- };
2109
- }
2110
- const expectedResource = `workflow-run:${repoId.id}`;
2111
- if (parsed.authz.resource !== expectedResource) {
2112
- return {
2113
- allowed: false,
2114
- reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
2115
- };
2116
- }
2117
- const expectedGrantVerb = repoActionToGrantVerb(action);
2118
- if (parsed.authz.grantVerb !== expectedGrantVerb) {
2119
- return {
2120
- allowed: false,
2121
- reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
2122
- };
2123
- }
2124
- if (parsed.authz.effect === "allow") {
2125
- return { allowed: true };
2126
- }
2127
- return {
2128
- allowed: false,
2129
- reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
2130
- };
2487
+ return authorizeUserPrincipal({
2488
+ principal,
2489
+ repoId,
2490
+ ref,
2491
+ action,
2492
+ resourcePrefix: "workflow-run",
2493
+ });
2131
2494
  }
2132
2495
  // Fail closed on any kind not handled above. The tenant-level
2133
2496
  // `workflow` principal kind (`@intx/types` principalKinds) is a
@@ -2641,26 +3004,31 @@ export async function markConsumed(store, principal, repoId, args) {
2641
3004
  };
2642
3005
  }
2643
3006
  /**
2644
- * Read the run event logs under `runs/` and return the set of
2645
- * `consumedMessageId`s belonging to NON-terminal runs -- the messages a
2646
- * live run still owns. The caller (the supervisor's spawn-time replay)
2647
- * feeds this into `replayProcessingToInbox`'s `ownedMessageIds` so a
2648
- * parked run's message is not re-admitted to inbox and dispatched a
2649
- * second time while the run is recovered by re-driving its durable log.
2650
- * Without this, the re-drive AND the re-triggered fresh run both re-park
2651
- * the same awaitSignal gate on the same runId, and the two concurrent
2652
- * runtime bodies race to a corrupt terminal.
3007
+ * Walk `runs/` once and return the two boot-recovery inputs the supervisor's
3008
+ * spawn needs, from a single traversal of the working tree via `getRepoDir`:
2653
3009
  *
2654
- * Reads the substrate's working tree via `getRepoDir`, mirroring the
2655
- * child's `discoverInFlightRuns`. The working tree tracks the run-event
2656
- * ref (`refs/heads/main`); the claim-check ref (`refs/heads/events`)
2657
- * cannot see it, which is why this lives at the caller rather than inside
2658
- * `replayProcessingToInbox`'s single-ref delta. A run whose log is sealed
2659
- * (combined `events.json`, only permitted for a terminated run) or
2660
- * carries a terminal event is excluded; an absent `runs/` directory
2661
- * yields an empty set.
3010
+ * - `ownedMessageIds`: the `consumedMessageId`s of NON-terminal runs -- the
3011
+ * messages a live run still owns. Spawn feeds this into
3012
+ * `replayProcessingToInbox`'s `ownedMessageIds` so a parked run's message is
3013
+ * not re-admitted to inbox and dispatched a second time while the run is
3014
+ * recovered by re-driving its durable log. Without this, the re-drive AND the
3015
+ * re-triggered fresh run both re-park the same awaitSignal gate on the same
3016
+ * runId, and the two concurrent runtime bodies race to a corrupt terminal.
3017
+ * - `pendingSealRunIds`: runs that are terminal but still in per-event form --
3018
+ * an interrupted fold left them unsealed. Spawn hands these to the recovery
3019
+ * sweep, which re-runs the idempotent fold. A terminal event is a *proposal*:
3020
+ * the authoritative decision is `compactRunEvents`, which independently
3021
+ * re-checks the run's max-seq event and no-ops a run that is not actually
3022
+ * terminal, so this scan may be loose.
3023
+ *
3024
+ * The working tree tracks the run-event ref (`refs/heads/main`); the
3025
+ * claim-check ref (`refs/heads/events`) cannot see it, which is why this lives
3026
+ * at the caller rather than inside `replayProcessingToInbox`'s single-ref
3027
+ * delta. A run whose log is sealed (combined `events.jsonl`, only permitted for
3028
+ * a terminated run) contributes to neither set; an absent `runs/` directory
3029
+ * yields empty results.
2662
3030
  */
2663
- export async function readOwnedMessageIds(store, repoId) {
3031
+ export async function scanRunsForBoot(store, repoId) {
2664
3032
  const fs = await import("node:fs/promises");
2665
3033
  const path = await import("node:path");
2666
3034
  const repoDir = store.getRepoDir(repoId);
@@ -2671,22 +3039,32 @@ export async function readOwnedMessageIds(store, repoId) {
2671
3039
  }
2672
3040
  catch (cause) {
2673
3041
  if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") {
2674
- return new Set();
3042
+ return { ownedMessageIds: new Set(), pendingSealRunIds: [] };
2675
3043
  }
2676
3044
  throw cause;
2677
3045
  }
2678
3046
  const owned = new Set();
3047
+ const pendingSealRunIds = [];
2679
3048
  for (const runId of runIds) {
2680
3049
  const runDir = path.join(runsDir, runId);
2681
3050
  // A sealed run (combined events file) is terminal by the handler's
2682
3051
  // own invariant -- only a terminated run is sealed -- so it owns
2683
- // nothing. Its presence also means the per-event directory is absent.
3052
+ // nothing and is already folded. Its presence also means the per-event
3053
+ // directory is absent.
2684
3054
  let sealed = false;
2685
3055
  try {
2686
3056
  await fs.access(path.join(runDir, WORKFLOW_RUN_EVENTS_FILE));
2687
3057
  sealed = true;
2688
3058
  }
2689
- catch {
3059
+ catch (cause) {
3060
+ // ENOENT is the normal "not sealed" case. Any other stat error leaves
3061
+ // the run to fall through to the events-dir read below, which resolves
3062
+ // it, so this catch is benign; warn so the anomaly is still visible.
3063
+ if (!(cause instanceof Error) ||
3064
+ !("code" in cause) ||
3065
+ cause.code !== "ENOENT") {
3066
+ logger.warn `scanRunsForBoot: stat of the sealed-log file for run ${runId} failed: ${cause instanceof Error ? cause.message : String(cause)}`;
3067
+ }
2690
3068
  sealed = false;
2691
3069
  }
2692
3070
  if (sealed)
@@ -2696,7 +3074,19 @@ export async function readOwnedMessageIds(store, repoId) {
2696
3074
  try {
2697
3075
  files = await fs.readdir(eventsDir);
2698
3076
  }
2699
- catch {
3077
+ catch (cause) {
3078
+ // ENOENT means the run has neither a sealed log nor a per-event
3079
+ // directory (grants may be staged before the first event); skip it.
3080
+ // A non-ENOENT error drops the run from BOTH result sets, and a live
3081
+ // run dropped from ownedMessageIds gets its message re-admitted and
3082
+ // dispatched a second time on the same runId -- the double-driver
3083
+ // corruption this scan exists to prevent. Surface it, but still skip:
3084
+ // aborting the whole boot scan over one run is worse.
3085
+ if (!(cause instanceof Error) ||
3086
+ !("code" in cause) ||
3087
+ cause.code !== "ENOENT") {
3088
+ logger.error `scanRunsForBoot: reading events for run ${runId} failed; skipping it may re-admit its message and start a second run on the same runId: ${cause instanceof Error ? cause.message : String(cause)}`;
3089
+ }
2700
3090
  continue;
2701
3091
  }
2702
3092
  let terminal = false;
@@ -2708,7 +3098,17 @@ export async function readOwnedMessageIds(store, repoId) {
2708
3098
  try {
2709
3099
  parsed = JSON.parse(await fs.readFile(path.join(eventsDir, file), "utf8"));
2710
3100
  }
2711
- catch {
3101
+ catch (cause) {
3102
+ // A corrupt or unreadable event file drops this run's
3103
+ // classification: a missed RunStarted re-admits its message (a
3104
+ // second run on the same runId), a missed terminal event skips a
3105
+ // needed seal. Surface it, but skip the file rather than abort the
3106
+ // scan. ENOENT here is a benign race (the file vanished mid-scan).
3107
+ if (!(cause instanceof Error) ||
3108
+ !("code" in cause) ||
3109
+ cause.code !== "ENOENT") {
3110
+ logger.error `scanRunsForBoot: reading event ${file} for run ${runId} failed; skipping it may re-admit its message and start a second run on the same runId: ${cause instanceof Error ? cause.message : String(cause)}`;
3111
+ }
2712
3112
  continue;
2713
3113
  }
2714
3114
  if (typeof parsed !== "object" ||
@@ -2729,44 +3129,31 @@ export async function readOwnedMessageIds(store, repoId) {
2729
3129
  consumedMessageId = mid;
2730
3130
  }
2731
3131
  }
2732
- if (terminal)
3132
+ if (terminal) {
3133
+ pendingSealRunIds.push(runId);
2733
3134
  continue;
3135
+ }
2734
3136
  if (consumedMessageId !== undefined)
2735
3137
  owned.add(consumedMessageId);
2736
3138
  }
2737
- return owned;
3139
+ return { ownedMessageIds: owned, pendingSealRunIds };
2738
3140
  }
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)) {
3141
+ /**
3142
+ * Classify a run's lifecycle from a read surface. The committed (git-object)
3143
+ * and working-tree (node:fs) readers share this core: a sealed combined log is
3144
+ * terminal; otherwise the latest per-event file decides terminal-vs-live, and a
3145
+ * run with no events is absent. The surface owns every read detail -- the
3146
+ * absent/ENOENT discrimination, the per-surface entry filter, and wrapping an
3147
+ * unreadable latest event as `workflow_run_event_unreadable` -- so this core
3148
+ * never sees a raw read error.
3149
+ */
3150
+ async function classifyRunLifecycle(surface) {
3151
+ if (await surface.sealedLogPresent())
2746
3152
  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);
3153
+ const entries = await surface.listEventEntries();
3154
+ const latest = entries.reduce((candidate, entry) => candidate === undefined || entry.seq > candidate.seq ? entry : candidate, undefined);
2761
3155
  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
- }
3156
+ const parsed = await surface.readEvent(latest);
2770
3157
  if (typeof parsed === "object" &&
2771
3158
  parsed !== null &&
2772
3159
  "type" in parsed &&
@@ -2775,7 +3162,40 @@ export async function readCommittedWorkflowRunLifecycle(reads, runId) {
2775
3162
  return "terminal";
2776
3163
  }
2777
3164
  }
2778
- return eventEntries.length === 0 ? "absent" : "live";
3165
+ return entries.length === 0 ? "absent" : "live";
3166
+ }
3167
+ /** Read one run's lifecycle from a committed workflow-run tree. */
3168
+ export async function readCommittedWorkflowRunLifecycle(reads, runId) {
3169
+ if (reads === null)
3170
+ return "absent";
3171
+ const runPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
3172
+ const eventsPath = `${runPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
3173
+ return classifyRunLifecycle({
3174
+ async sealedLogPresent() {
3175
+ const runChildren = await reads.listDir(runPath);
3176
+ return runChildren.some((entry) => entry.type === "blob" && entry.name === WORKFLOW_RUN_EVENTS_FILE);
3177
+ },
3178
+ async listEventEntries() {
3179
+ const eventEntries = await reads.listDir(eventsPath);
3180
+ return eventEntries.flatMap((entry) => {
3181
+ if (entry.type !== "blob")
3182
+ return [];
3183
+ const seq = parseEventSeq(entry.name);
3184
+ return seq === null ? [] : [{ seq, name: entry.name, oid: entry.oid }];
3185
+ });
3186
+ },
3187
+ async readEvent(entry) {
3188
+ const eventPath = `${eventsPath}/${entry.name}`;
3189
+ try {
3190
+ return JSON.parse(new TextDecoder().decode(await reads.readBlobByOid(entry.oid)));
3191
+ }
3192
+ catch (cause) {
3193
+ throw new Error(`workflow_run_event_unreadable: ${eventPath}`, {
3194
+ cause,
3195
+ });
3196
+ }
3197
+ },
3198
+ });
2779
3199
  }
2780
3200
  /**
2781
3201
  * Read the durable lifecycle of one run from the workflow-run working tree.
@@ -2793,56 +3213,52 @@ export async function readWorkflowRunLifecycle(store, repoId, runId) {
2793
3213
  const fs = await import("node:fs/promises");
2794
3214
  const path = await import("node:path");
2795
3215
  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
3216
  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";
3217
+ return classifyRunLifecycle({
3218
+ async sealedLogPresent() {
3219
+ try {
3220
+ await fs.access(path.join(runDir, WORKFLOW_RUN_EVENTS_FILE));
3221
+ return true;
3222
+ }
3223
+ catch (cause) {
3224
+ if (!(cause instanceof Error) ||
3225
+ !("code" in cause) ||
3226
+ cause.code !== "ENOENT") {
3227
+ throw cause;
3228
+ }
3229
+ return false;
3230
+ }
3231
+ },
3232
+ async listEventEntries() {
3233
+ let files;
3234
+ try {
3235
+ files = await fs.readdir(eventsDir);
3236
+ }
3237
+ catch (cause) {
3238
+ if (cause instanceof Error &&
3239
+ "code" in cause &&
3240
+ cause.code === "ENOENT") {
3241
+ return [];
3242
+ }
3243
+ throw cause;
3244
+ }
3245
+ return files.flatMap((file) => {
3246
+ const seq = parseEventSeq(file);
3247
+ return seq === null ? [] : [{ seq, name: file }];
3248
+ });
3249
+ },
3250
+ async readEvent(entry) {
3251
+ const eventPath = path.join(eventsDir, entry.name);
3252
+ try {
3253
+ return JSON.parse(await fs.readFile(eventPath, "utf8"));
3254
+ }
3255
+ catch (cause) {
3256
+ throw new Error(`workflow_run_event_unreadable: ${eventPath}`, {
3257
+ cause,
3258
+ });
3259
+ }
3260
+ },
3261
+ });
2846
3262
  }
2847
3263
  /**
2848
3264
  * Recovery path: move every processing entry at `address` back to