@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.
- package/dist/agent-repo.d.ts +14 -2
- package/dist/agent-repo.js +17 -4
- package/dist/agent-state-kind.js +14 -63
- package/dist/asset-service.js +14 -10
- package/dist/credential-push.d.ts +48 -4
- package/dist/credential-push.js +138 -6
- package/dist/event-collector-registry.d.ts +2 -1
- package/dist/event-collector-registry.js +38 -9
- package/dist/event-collector.d.ts +11 -1
- package/dist/event-collector.js +36 -3
- package/dist/hub-session-lookups.d.ts +1 -1
- package/dist/hub-session-lookups.js +68 -72
- package/dist/hub-session-orchestrator.d.ts +2 -3
- package/dist/hub-session-orchestrator.js +13 -12
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/reconciliation-scheduler.d.ts +14 -0
- package/dist/reconciliation-scheduler.js +55 -0
- package/dist/repo-store/index.d.ts +1 -0
- package/dist/repo-store/index.js +1 -0
- package/dist/repo-store/user-principal-gate.d.ts +26 -0
- package/dist/repo-store/user-principal-gate.js +78 -0
- package/dist/session-service.d.ts +66 -121
- package/dist/session-service.js +444 -411
- package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
- package/dist/sidecar-allocation/capability-policy.js +124 -0
- package/dist/sidecar-allocation/contracts.d.ts +29 -6
- package/dist/sidecar-allocation/contracts.js +7 -2
- package/dist/sidecar-allocation/index.d.ts +4 -3
- package/dist/sidecar-allocation/index.js +3 -2
- package/dist/sidecar-allocation/operation.d.ts +10 -0
- package/dist/sidecar-allocation/operation.js +54 -0
- package/dist/sidecar-allocation/plugin-registry.d.ts +16 -3
- package/dist/sidecar-allocation/plugin-registry.js +36 -12
- package/dist/sidecar-allocation/reconciler.d.ts +16 -4
- package/dist/sidecar-allocation/reconciler.js +486 -92
- package/dist/skill-kind.js +8 -62
- package/dist/substrate.d.ts +1 -1
- package/dist/substrate.js +1 -1
- package/dist/workflow-allocation-service.d.ts +21 -15
- package/dist/workflow-allocation-service.js +440 -125
- package/dist/workflow-dispatch-service.d.ts +4 -2
- package/dist/workflow-dispatch-service.js +89 -26
- package/dist/workflow-kind.d.ts +12 -0
- package/dist/workflow-kind.js +17 -60
- package/dist/workflow-probe-gate.d.ts +99 -27
- package/dist/workflow-probe-gate.js +196 -21
- package/dist/workflow-run-kind.d.ts +112 -19
- package/dist/workflow-run-kind.js +626 -210
- package/dist/workflow-run-restore.d.ts +1 -0
- package/dist/workflow-run-restore.js +5 -1
- package/dist/workflow-source-pins.d.ts +8 -0
- package/dist/workflow-source-pins.js +14 -0
- package/dist/ws/index.d.ts +1 -1
- package/dist/ws/index.js +1 -1
- package/dist/ws/pending-tracker.d.ts +93 -0
- package/dist/ws/pending-tracker.js +132 -0
- package/dist/ws/sidecar-events.d.ts +43 -29
- package/dist/ws/sidecar-events.js +0 -2
- package/dist/ws/sidecar-handler.d.ts +122 -85
- package/dist/ws/sidecar-handler.js +925 -878
- package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
- package/dist/ws/sidecar-handler.test-helpers.js +95 -0
- package/dist/ws/sidecar-token-authenticator.js +37 -23
- package/package.json +13 -13
- package/dist/sidecar-allocation/placement-policy.d.ts +0 -11
- 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 {
|
|
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 "${
|
|
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
|
-
//
|
|
570
|
-
//
|
|
571
|
-
//
|
|
572
|
-
// child emits its first event. Carry
|
|
573
|
-
//
|
|
574
|
-
//
|
|
575
|
-
|
|
576
|
-
|
|
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
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
*
|
|
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
|
|
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: `
|
|
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: `
|
|
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 =
|
|
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 =
|
|
1943
|
+
const roundTrip = checkUrlSegmentRoundTrip(segment);
|
|
1699
1944
|
if (!roundTrip.ok) {
|
|
1700
1945
|
return {
|
|
1701
1946
|
ok: false,
|
|
1702
|
-
reason: `agent-state
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
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
|
-
*
|
|
2645
|
-
*
|
|
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
|
-
*
|
|
2655
|
-
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
2661
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
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
|