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