@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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { DBExecutor, EnqueueWorkflowRunDispatchArgs, EnqueueWorkflowRunDispatchResult, EnqueueWorkflowSignalDispatchArgs, SidecarAllocationStore, WorkflowRunDispatchStore } from "@intx/db";
|
|
2
|
+
import type { SidecarAllocationRouter } from "./ws/sidecar-handler.js";
|
|
3
|
+
type DispatchStore = Pick<WorkflowRunDispatchStore, "acknowledge" | "claimNextPending" | "enqueue" | "enqueueSignal" | "requeueUnsettled" | "scheduleRetry" | "settle">;
|
|
4
|
+
type AllocationStore = Pick<SidecarAllocationStore, "findByAnchorRunId">;
|
|
5
|
+
type DispatchRouter = Pick<SidecarAllocationRouter, "sendSignalDeliverToAllocation" | "sendWorkflowRunDispatchToAllocation">;
|
|
6
|
+
export type WorkflowDispatchAcknowledgement = {
|
|
7
|
+
readonly allocationId: string;
|
|
8
|
+
readonly anchorRunId: string;
|
|
9
|
+
readonly generation: number;
|
|
10
|
+
readonly messageId: string;
|
|
11
|
+
};
|
|
12
|
+
export type WorkflowDispatchService = {
|
|
13
|
+
enqueue(args: EnqueueWorkflowRunDispatchArgs, tx?: DBExecutor): Promise<EnqueueWorkflowRunDispatchResult>;
|
|
14
|
+
enqueueSignal(args: EnqueueWorkflowSignalDispatchArgs, tx?: DBExecutor): Promise<EnqueueWorkflowRunDispatchResult>;
|
|
15
|
+
acknowledge(args: WorkflowDispatchAcknowledgement): Promise<void>;
|
|
16
|
+
settle(anchorRunId: string, messageId: string): Promise<void>;
|
|
17
|
+
requeueForReadyAllocation(anchorRunId: string): Promise<number>;
|
|
18
|
+
reconcileNext(): Promise<boolean>;
|
|
19
|
+
reconcileUntilIdle(maxIterations?: number): Promise<number>;
|
|
20
|
+
wake(): void;
|
|
21
|
+
};
|
|
22
|
+
export type WorkflowDispatchServiceDeps = {
|
|
23
|
+
readonly dispatchStore: DispatchStore;
|
|
24
|
+
readonly allocationStore: AllocationStore;
|
|
25
|
+
readonly router: DispatchRouter;
|
|
26
|
+
/** Resolve the deployment anchor's durable routing address. */
|
|
27
|
+
readonly resolveAnchorAddress: (anchorRunId: string) => Promise<string | null>;
|
|
28
|
+
readonly leaseDurationMs?: number;
|
|
29
|
+
readonly retryDelayMs?: (attempt: number) => number;
|
|
30
|
+
readonly now?: () => Date;
|
|
31
|
+
readonly createLeaseId?: () => string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Drives Hub-owned workflow triggers onto exclusive sidecars. The database
|
|
35
|
+
* row is the delivery authority: websocket acceptance never deletes the raw
|
|
36
|
+
* payload, and a generation replacement requeues every row that has not been
|
|
37
|
+
* settled by the workflow-run Git claim-check.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs, retryDelayMs, now, createLeaseId, }: WorkflowDispatchServiceDeps): WorkflowDispatchService;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { getLogger } from "@intx/log";
|
|
2
|
+
import { base64Encode, hexEncode } from "@intx/types";
|
|
3
|
+
import { SignalDeliverFrame } from "@intx/types/sidecar";
|
|
4
|
+
const logger = getLogger(["hub", "workflow-dispatch"]);
|
|
5
|
+
const DEFAULT_LEASE_DURATION_MS = 30_000;
|
|
6
|
+
function defaultRetryDelay(attempt) {
|
|
7
|
+
return Math.min(500 * 2 ** Math.min(attempt, 6), 30_000);
|
|
8
|
+
}
|
|
9
|
+
function randomLeaseId() {
|
|
10
|
+
return `dispatch_lease_${hexEncode(crypto.getRandomValues(new Uint8Array(16)))}`;
|
|
11
|
+
}
|
|
12
|
+
function targetForReadyAllocation(allocation) {
|
|
13
|
+
if (allocation.status !== "allocated" ||
|
|
14
|
+
allocation.ensureAcceptedGeneration !== allocation.generation ||
|
|
15
|
+
allocation.connectDeadline !== undefined) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
allocationId: allocation.id,
|
|
20
|
+
generation: allocation.generation,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Drives Hub-owned workflow triggers onto exclusive sidecars. The database
|
|
25
|
+
* row is the delivery authority: websocket acceptance never deletes the raw
|
|
26
|
+
* payload, and a generation replacement requeues every row that has not been
|
|
27
|
+
* settled by the workflow-run Git claim-check.
|
|
28
|
+
*/
|
|
29
|
+
export function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, retryDelayMs = defaultRetryDelay, now = () => new Date(), createLeaseId = randomLeaseId, }) {
|
|
30
|
+
if (leaseDurationMs <= 0) {
|
|
31
|
+
throw new Error("leaseDurationMs must be positive");
|
|
32
|
+
}
|
|
33
|
+
let drainPromise = null;
|
|
34
|
+
function retryAt(attempt) {
|
|
35
|
+
return new Date(now().getTime() + retryDelayMs(attempt));
|
|
36
|
+
}
|
|
37
|
+
async function retry(dispatch, leaseId, code, message) {
|
|
38
|
+
await dispatchStore.scheduleRetry({
|
|
39
|
+
dispatchId: dispatch.id,
|
|
40
|
+
nextAttemptAt: retryAt(dispatch.attemptCount),
|
|
41
|
+
code,
|
|
42
|
+
message,
|
|
43
|
+
expectedLeaseId: leaseId,
|
|
44
|
+
now: now(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function reconcileNext() {
|
|
48
|
+
const leaseId = createLeaseId();
|
|
49
|
+
const dispatch = await dispatchStore.claimNextPending({
|
|
50
|
+
leaseId,
|
|
51
|
+
leaseDurationMs,
|
|
52
|
+
});
|
|
53
|
+
if (dispatch === null)
|
|
54
|
+
return false;
|
|
55
|
+
const allocation = await allocationStore.findByAnchorRunId(dispatch.anchorRunId);
|
|
56
|
+
if (allocation === null) {
|
|
57
|
+
await retry(dispatch, leaseId, "allocation_missing", `No sidecar allocation exists for workflow anchor ${dispatch.anchorRunId}`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
const target = targetForReadyAllocation(allocation);
|
|
61
|
+
if (target === null) {
|
|
62
|
+
await retry(dispatch, leaseId, "allocation_not_ready", `Sidecar allocation ${allocation.id} is not ready for delivery`);
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
const agentAddress = await resolveAnchorAddress(dispatch.anchorRunId);
|
|
66
|
+
if (agentAddress === null) {
|
|
67
|
+
await retry(dispatch, leaseId, "anchor_address_missing", `Workflow anchor ${dispatch.anchorRunId} has no routing address`);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
if (dispatch.kind === "signal") {
|
|
72
|
+
const signal = SignalDeliverFrame.assert(JSON.parse(new TextDecoder().decode(dispatch.rawMessage)));
|
|
73
|
+
await router.sendSignalDeliverToAllocation(target, {
|
|
74
|
+
agentAddress: signal.agentAddress,
|
|
75
|
+
runId: signal.runId,
|
|
76
|
+
signalName: signal.signalName,
|
|
77
|
+
signalId: signal.signalId,
|
|
78
|
+
payload: signal.payload,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
await router.sendWorkflowRunDispatchToAllocation(target, agentAddress,
|
|
83
|
+
// Every trigger of a deployment uses its stable mail address as the
|
|
84
|
+
// supervisor run id.
|
|
85
|
+
agentAddress, dispatch.stepGrants, base64Encode(dispatch.rawMessage), dispatch.messageId);
|
|
86
|
+
}
|
|
87
|
+
// Keep the delivery lease until the sidecar acknowledges its durable
|
|
88
|
+
// inbox write. If that ack never arrives, lease expiry makes the same
|
|
89
|
+
// immutable payload claimable again.
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
await retry(dispatch, leaseId, "dispatch_unroutable", error instanceof Error ? error.message : String(error));
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
async function reconcileUntilIdle(maxIterations = 100) {
|
|
97
|
+
let reconciled = 0;
|
|
98
|
+
while (reconciled < maxIterations && (await reconcileNext())) {
|
|
99
|
+
reconciled += 1;
|
|
100
|
+
}
|
|
101
|
+
return reconciled;
|
|
102
|
+
}
|
|
103
|
+
function wake() {
|
|
104
|
+
if (drainPromise !== null)
|
|
105
|
+
return;
|
|
106
|
+
drainPromise = Promise.resolve()
|
|
107
|
+
.then(async () => {
|
|
108
|
+
await reconcileUntilIdle();
|
|
109
|
+
})
|
|
110
|
+
.catch((error) => {
|
|
111
|
+
logger.error `Workflow dispatch reconciliation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
112
|
+
})
|
|
113
|
+
.finally(() => {
|
|
114
|
+
drainPromise = null;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
async enqueue(args, tx) {
|
|
119
|
+
const result = await dispatchStore.enqueue(args, tx);
|
|
120
|
+
wake();
|
|
121
|
+
return result;
|
|
122
|
+
},
|
|
123
|
+
async enqueueSignal(args, tx) {
|
|
124
|
+
const result = await dispatchStore.enqueueSignal(args, tx);
|
|
125
|
+
wake();
|
|
126
|
+
return result;
|
|
127
|
+
},
|
|
128
|
+
async acknowledge(args) {
|
|
129
|
+
await dispatchStore.acknowledge({
|
|
130
|
+
...args,
|
|
131
|
+
now: now(),
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
async settle(anchorRunId, messageId) {
|
|
135
|
+
await dispatchStore.settle(anchorRunId, messageId, now());
|
|
136
|
+
},
|
|
137
|
+
async requeueForReadyAllocation(anchorRunId) {
|
|
138
|
+
const count = await dispatchStore.requeueUnsettled(anchorRunId);
|
|
139
|
+
wake();
|
|
140
|
+
return count;
|
|
141
|
+
},
|
|
142
|
+
reconcileNext,
|
|
143
|
+
reconcileUntilIdle,
|
|
144
|
+
wake,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CommittedReads } from "./repo-store/index.js";
|
|
2
|
+
export type ConsumedWorkflowDispatch = {
|
|
3
|
+
readonly messageId: string;
|
|
4
|
+
readonly address: string;
|
|
5
|
+
readonly rejection?: {
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly message: string;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export type ReceivedWorkflowSignal = {
|
|
11
|
+
readonly runId: string;
|
|
12
|
+
readonly signalId: string;
|
|
13
|
+
};
|
|
14
|
+
export type AcceptedWorkflowDispatch = {
|
|
15
|
+
readonly runId: string;
|
|
16
|
+
readonly messageId: string;
|
|
17
|
+
readonly kind: "mail" | "signal";
|
|
18
|
+
};
|
|
19
|
+
/** Enumerate dispatches durably accepted by one live or sealed run log. */
|
|
20
|
+
export declare function listAcceptedWorkflowDispatches(reads: CommittedReads, runId: string, targetMessageIds?: ReadonlySet<string>): Promise<AcceptedWorkflowDispatch[]>;
|
|
21
|
+
/** Enumerate durable SignalReceived ids from one live or sealed run log. */
|
|
22
|
+
export declare function listReceivedWorkflowSignals(reads: CommittedReads, runId: string): Promise<ReceivedWorkflowSignal[]>;
|
|
23
|
+
/**
|
|
24
|
+
* Enumerate the workflow-run claim-check's retained consumed index. The kind
|
|
25
|
+
* validator guarantees this tree shape before the ref advances; the runtime
|
|
26
|
+
* validator here keeps the Git-to-database projection fail-closed if that
|
|
27
|
+
* invariant is ever violated.
|
|
28
|
+
*/
|
|
29
|
+
export declare function listConsumedWorkflowDispatches(reads: CommittedReads): Promise<ConsumedWorkflowDispatch[]>;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
|
|
3
|
+
import { WORKFLOW_RUN_ADDRESSES_PREFIX, WORKFLOW_RUN_CONSUMED_DIR, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
|
|
4
|
+
const ConsumedEnvelope = type({
|
|
5
|
+
messageId: "string",
|
|
6
|
+
address: "string",
|
|
7
|
+
"rejection?": {
|
|
8
|
+
code: "string > 0",
|
|
9
|
+
message: "string > 0",
|
|
10
|
+
},
|
|
11
|
+
"+": "ignore",
|
|
12
|
+
});
|
|
13
|
+
const SignalReceived = type({
|
|
14
|
+
type: "'SignalReceived'",
|
|
15
|
+
signalId: "string",
|
|
16
|
+
"+": "ignore",
|
|
17
|
+
});
|
|
18
|
+
const RunStarted = type({
|
|
19
|
+
type: "'RunStarted'",
|
|
20
|
+
"consumedMessageId?": "string",
|
|
21
|
+
"+": "ignore",
|
|
22
|
+
});
|
|
23
|
+
function projectAcceptedDispatch(raw, path) {
|
|
24
|
+
let decoded;
|
|
25
|
+
try {
|
|
26
|
+
decoded = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (cause) {
|
|
29
|
+
throw new Error(`workflow_dispatch_event_invalid_json: ${path}`, {
|
|
30
|
+
cause,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (typeof decoded !== "object" || decoded === null || !("type" in decoded)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (decoded.type === "SignalReceived") {
|
|
37
|
+
const signal = SignalReceived(decoded);
|
|
38
|
+
if (signal instanceof type.errors) {
|
|
39
|
+
throw new Error(`workflow_dispatch_signal_invalid: ${path}: ${signal.summary}`);
|
|
40
|
+
}
|
|
41
|
+
return { kind: "signal", messageId: signal.signalId };
|
|
42
|
+
}
|
|
43
|
+
if (decoded.type === "RunStarted") {
|
|
44
|
+
const started = RunStarted(decoded);
|
|
45
|
+
if (started instanceof type.errors) {
|
|
46
|
+
throw new Error(`workflow_dispatch_run_started_invalid: ${path}: ${started.summary}`);
|
|
47
|
+
}
|
|
48
|
+
return started.consumedMessageId === undefined
|
|
49
|
+
? null
|
|
50
|
+
: { kind: "mail", messageId: started.consumedMessageId };
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Enumerate dispatches durably accepted by one live or sealed run log. */
|
|
55
|
+
export async function listAcceptedWorkflowDispatches(reads, runId, targetMessageIds) {
|
|
56
|
+
if (targetMessageIds?.size === 0)
|
|
57
|
+
return [];
|
|
58
|
+
const accepted = [];
|
|
59
|
+
const remaining = targetMessageIds === undefined ? undefined : new Set(targetMessageIds);
|
|
60
|
+
const accept = (dispatch) => {
|
|
61
|
+
if (dispatch === null)
|
|
62
|
+
return;
|
|
63
|
+
if (remaining !== undefined && !remaining.delete(dispatch.messageId))
|
|
64
|
+
return;
|
|
65
|
+
accepted.push({ runId, ...dispatch });
|
|
66
|
+
};
|
|
67
|
+
const runPath = `${WORKFLOW_RUN_RUNS_PREFIX}/${runId}`;
|
|
68
|
+
const children = await reads.listDir(runPath);
|
|
69
|
+
const combined = children.find((entry) => entry.type === "blob" && entry.name === WORKFLOW_RUN_EVENTS_FILE);
|
|
70
|
+
if (combined !== undefined) {
|
|
71
|
+
const raw = new TextDecoder().decode(await reads.readBlobByOid(combined.oid));
|
|
72
|
+
const lines = splitCombinedEventLog(raw);
|
|
73
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
74
|
+
const line = lines[index];
|
|
75
|
+
if (line === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
accept(projectAcceptedDispatch(line, `${runPath}/${WORKFLOW_RUN_EVENTS_FILE}:${String(index + 1)}`));
|
|
78
|
+
if (remaining?.size === 0)
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return accepted.reverse();
|
|
82
|
+
}
|
|
83
|
+
const eventsPath = `${runPath}/${WORKFLOW_RUN_EVENTS_DIR}`;
|
|
84
|
+
const eventEntries = (await reads.listDir(eventsPath))
|
|
85
|
+
.filter((entry) => entry.type === "blob" && entry.name.endsWith(".json"))
|
|
86
|
+
.sort((left, right) => right.name.localeCompare(left.name, undefined, {
|
|
87
|
+
numeric: true,
|
|
88
|
+
}));
|
|
89
|
+
for (const eventEntry of eventEntries) {
|
|
90
|
+
const raw = new TextDecoder().decode(await reads.readBlobByOid(eventEntry.oid));
|
|
91
|
+
accept(projectAcceptedDispatch(raw, `${eventsPath}/${eventEntry.name}`));
|
|
92
|
+
if (remaining?.size === 0)
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return accepted.reverse();
|
|
96
|
+
}
|
|
97
|
+
/** Enumerate durable SignalReceived ids from one live or sealed run log. */
|
|
98
|
+
export async function listReceivedWorkflowSignals(reads, runId) {
|
|
99
|
+
return (await listAcceptedWorkflowDispatches(reads, runId))
|
|
100
|
+
.filter((dispatch) => dispatch.kind === "signal")
|
|
101
|
+
.map((dispatch) => ({ runId, signalId: dispatch.messageId }));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Enumerate the workflow-run claim-check's retained consumed index. The kind
|
|
105
|
+
* validator guarantees this tree shape before the ref advances; the runtime
|
|
106
|
+
* validator here keeps the Git-to-database projection fail-closed if that
|
|
107
|
+
* invariant is ever violated.
|
|
108
|
+
*/
|
|
109
|
+
export async function listConsumedWorkflowDispatches(reads) {
|
|
110
|
+
const consumed = [];
|
|
111
|
+
for (const addressEntry of await reads.listDir(WORKFLOW_RUN_ADDRESSES_PREFIX)) {
|
|
112
|
+
if (addressEntry.type !== "tree")
|
|
113
|
+
continue;
|
|
114
|
+
const consumedPath = `${WORKFLOW_RUN_ADDRESSES_PREFIX}/${addressEntry.name}/${WORKFLOW_RUN_CONSUMED_DIR}`;
|
|
115
|
+
for (const entry of await reads.listDir(consumedPath)) {
|
|
116
|
+
if (entry.type !== "blob" || !entry.name.endsWith(".json"))
|
|
117
|
+
continue;
|
|
118
|
+
const raw = await reads.readBlobByOid(entry.oid);
|
|
119
|
+
let decoded;
|
|
120
|
+
try {
|
|
121
|
+
decoded = JSON.parse(new TextDecoder().decode(raw));
|
|
122
|
+
}
|
|
123
|
+
catch (cause) {
|
|
124
|
+
throw new Error(`workflow_dispatch_consumed_invalid_json: ${consumedPath}/${entry.name}`, { cause });
|
|
125
|
+
}
|
|
126
|
+
const envelope = ConsumedEnvelope(decoded);
|
|
127
|
+
if (envelope instanceof type.errors) {
|
|
128
|
+
throw new Error(`workflow_dispatch_consumed_invalid: ${consumedPath}/${entry.name}: ${envelope.summary}`);
|
|
129
|
+
}
|
|
130
|
+
consumed.push({
|
|
131
|
+
messageId: envelope.messageId,
|
|
132
|
+
address: envelope.address,
|
|
133
|
+
...(envelope.rejection !== undefined
|
|
134
|
+
? { rejection: envelope.rejection }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return consumed;
|
|
140
|
+
}
|
package/dist/workflow-kind.d.ts
CHANGED
|
@@ -9,13 +9,29 @@ export type WorkflowSidecarPrincipal = {
|
|
|
9
9
|
export type WorkflowPrincipal = WorkflowHubPrincipal | WorkflowSidecarPrincipal;
|
|
10
10
|
export declare const WORKFLOW_JSON_PATH = "workflow.json";
|
|
11
11
|
export declare const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
|
|
12
|
-
export declare const
|
|
12
|
+
export declare const PACKAGE_JSON_PATH = "package.json";
|
|
13
|
+
export declare const NODE_MODULES_PATH = "node_modules";
|
|
14
|
+
export declare const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
|
|
13
15
|
export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
14
16
|
id: string;
|
|
15
17
|
triggers: unknown[];
|
|
16
18
|
steps: Record<string, unknown>;
|
|
17
19
|
stepOrder: string[];
|
|
18
20
|
state?: Record<string, unknown>;
|
|
21
|
+
grantRequirements?: {
|
|
22
|
+
resource: string;
|
|
23
|
+
action: string;
|
|
24
|
+
source: "creator" | "invoker";
|
|
25
|
+
effect?: "allow" | "deny" | "ask";
|
|
26
|
+
conditions?: Record<string, unknown> | null;
|
|
27
|
+
}[];
|
|
28
|
+
credentialBindings?: {
|
|
29
|
+
package: string;
|
|
30
|
+
handle: string;
|
|
31
|
+
provider: string;
|
|
32
|
+
locator: "tenant";
|
|
33
|
+
name?: string;
|
|
34
|
+
}[];
|
|
19
35
|
}, {}>;
|
|
20
36
|
export declare const workflowKindHandler: KindHandler;
|
|
21
37
|
export declare const workflowAuthorize: AuthorizeFn;
|
package/dist/workflow-kind.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
// KindHandler for the `workflow` asset kind.
|
|
2
2
|
//
|
|
3
|
-
// A workflow asset is a
|
|
4
|
-
//
|
|
3
|
+
// A workflow asset is a codebase: a top-level `package.json` declaring an
|
|
4
|
+
// `interchange.workflow` entry module plus arbitrary source files. The sidecar
|
|
5
|
+
// materializes the codebase into a closure and evaluates the pinned entry to the
|
|
6
|
+
// definition. `validatePush` requires the `package.json`; a tree that lacks one
|
|
7
|
+
// is rejected. The legacy `workflow.json` envelope form is no longer accepted at
|
|
8
|
+
// the push boundary.
|
|
5
9
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// - `.gitignore` — supplied by the asset routes' genesis init body.
|
|
14
|
-
//
|
|
15
|
-
// Any top-level entry outside this set fails the push.
|
|
10
|
+
// Source files are unconstrained, but the push validates the manifest's shape
|
|
11
|
+
// and the entry-path's containment, and refuses an envelope-only
|
|
12
|
+
// `capability-declarations.json`, a committed `node_modules`, and an ambiguous
|
|
13
|
+
// tree that also carries an envelope-valid `workflow.json`, so one asset resolves
|
|
14
|
+
// to exactly one definition. The codebase shape accepts both a single package and
|
|
15
|
+
// a `workspaces` monorepo; for a monorepo the push validates only the root's
|
|
16
|
+
// well-formedness and leaves per-member validation to the resolver.
|
|
16
17
|
//
|
|
17
18
|
// Authz:
|
|
18
19
|
// - hub principal: full access.
|
|
@@ -22,17 +23,16 @@
|
|
|
22
23
|
// used by skill assets.
|
|
23
24
|
import { type } from "arktype";
|
|
24
25
|
import { getLogger } from "@intx/log";
|
|
26
|
+
import { CredentialBinding, GrantRequirement } from "@intx/types";
|
|
27
|
+
import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
|
|
25
28
|
import { glob, repoActionToGrantVerb } from "@intx/hub-common";
|
|
26
29
|
import { UserPrincipal, } from "./repo-store/index.js";
|
|
27
30
|
const logger = getLogger(["hub-sessions", "workflow-kind"]);
|
|
28
31
|
export const WORKFLOW_JSON_PATH = "workflow.json";
|
|
29
32
|
export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
|
|
30
|
-
export const
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
CAPABILITY_DECLARATIONS_JSON_PATH,
|
|
34
|
-
WORKFLOW_GITIGNORE_PATH,
|
|
35
|
-
]);
|
|
33
|
+
export const PACKAGE_JSON_PATH = "package.json";
|
|
34
|
+
export const NODE_MODULES_PATH = "node_modules";
|
|
35
|
+
export const PNPM_WORKSPACE_PATH = "pnpm-workspace.yaml";
|
|
36
36
|
/**
|
|
37
37
|
* Structural arktype validator for the `workflow.json` envelope. The
|
|
38
38
|
* substrate checks the cross-cutting shape of `WorkflowDefinition`
|
|
@@ -40,10 +40,10 @@ const ALLOWED_TOP_LEVEL = new Set([
|
|
|
40
40
|
* `stepOrder`) but does not re-derive `defineWorkflow`'s DAG-level
|
|
41
41
|
* validation here — primitive-level shape, default-input application,
|
|
42
42
|
* and `after`-ref resolution belong to the runtime layer that hydrates
|
|
43
|
-
* the definition.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
43
|
+
* the definition. The codebase push uses this validator to detect an
|
|
44
|
+
* ambiguous tree that also carries an envelope-valid `workflow.json`,
|
|
45
|
+
* and the hydrate-time definition loaders reuse it to validate a
|
|
46
|
+
* materialized definition before instantiation.
|
|
47
47
|
*/
|
|
48
48
|
const StepsObject = type("Record<string, unknown>").narrow((value, ctx) => {
|
|
49
49
|
if (Array.isArray(value)) {
|
|
@@ -63,20 +63,22 @@ export const workflowDefinitionEnvelopeSchema = type({
|
|
|
63
63
|
steps: StepsObject,
|
|
64
64
|
stepOrder: "string[]",
|
|
65
65
|
"state?": StateObject,
|
|
66
|
+
// `grantRequirements` passes through the envelope whether or not it is
|
|
67
|
+
// declared here: arktype's `.onUndeclaredKey("ignore")` below is
|
|
68
|
+
// passthrough, not stripping (only `"delete"` strips), so the hydrate read
|
|
69
|
+
// sees the field either way. Declaring it here VALIDATES declared
|
|
70
|
+
// requirements at the deploy boundary — a malformed `source` is rejected
|
|
71
|
+
// rather than passed through unchecked — as defense in depth alongside the
|
|
72
|
+
// trigger route's own `GrantRequirements` re-validation. Compose the
|
|
73
|
+
// exported `GrantRequirement` arktype rather than restating its shape so the
|
|
74
|
+
// envelope and the definition stay in lockstep.
|
|
75
|
+
"grantRequirements?": GrantRequirement.array(),
|
|
76
|
+
// `credentialBindings` is validated here too -- same defense-in-depth
|
|
77
|
+
// rationale as grantRequirements above: a malformed binding (bad locator,
|
|
78
|
+
// authority, or handle) is rejected at the deploy boundary rather than
|
|
79
|
+
// passed through to launch-time resolution unchecked.
|
|
80
|
+
"credentialBindings?": CredentialBinding.array(),
|
|
66
81
|
}).onUndeclaredKey("ignore");
|
|
67
|
-
/**
|
|
68
|
-
* Capability-declarations.json is held to "is a JSON object" at this
|
|
69
|
-
* commit; the per-step structure is owned by the capability-walk
|
|
70
|
-
* module that authors the file. `Record<string, unknown>` on its own
|
|
71
|
-
* accepts arrays under arktype's structural-object semantics, so the
|
|
72
|
-
* push validator pairs it with an array-rejection narrow.
|
|
73
|
-
*/
|
|
74
|
-
const CapabilityDeclarationsObject = type("Record<string, unknown>").narrow((value, ctx) => {
|
|
75
|
-
if (Array.isArray(value)) {
|
|
76
|
-
return ctx.mustBe("a JSON object, not an array");
|
|
77
|
-
}
|
|
78
|
-
return true;
|
|
79
|
-
});
|
|
80
82
|
const SidecarPrincipal = type({
|
|
81
83
|
kind: "'sidecar'",
|
|
82
84
|
agentId: "string",
|
|
@@ -105,58 +107,99 @@ async function readJSONBlob(path, readBlob) {
|
|
|
105
107
|
}
|
|
106
108
|
return { ok: true, value: parsed };
|
|
107
109
|
}
|
|
110
|
+
function isJSONObject(value) {
|
|
111
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
112
|
+
}
|
|
113
|
+
function rejectPush(repoId, ref, reason) {
|
|
114
|
+
logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
|
|
115
|
+
return { ok: false, reason };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Validate the codebase shape: a top-level `package.json` declaring a contained
|
|
119
|
+
* `interchange.workflow` entry (single package), or a `workspaces` monorepo
|
|
120
|
+
* whose root well-formedness is checked and whose members are deferred to the
|
|
121
|
+
* resolver, plus arbitrary source files. Entered when the tree carries a
|
|
122
|
+
* `package.json`. Source files are unconstrained, but the push refuses the
|
|
123
|
+
* envelope-only `capability-declarations.json`, a committed `node_modules`, and
|
|
124
|
+
* an ambiguous tree that also carries an envelope-valid `workflow.json`, so one
|
|
125
|
+
* asset resolves to exactly one definition.
|
|
126
|
+
*
|
|
127
|
+
* The push validates STRUCTURE only. It never imports or evaluates the entry
|
|
128
|
+
* module -- that runs author code and is the sidecar's sandboxed job. The
|
|
129
|
+
* entry-path containment check is the string-level half of the loader's rule
|
|
130
|
+
* (`isContainedEntryPath`); the realpath-based symlink half runs at load time
|
|
131
|
+
* against the materialized directory, which the hub does not have here.
|
|
132
|
+
*/
|
|
133
|
+
async function validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob) {
|
|
134
|
+
if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) {
|
|
135
|
+
return rejectPush(repoId, ref, `${CAPABILITY_DECLARATIONS_JSON_PATH} is an envelope-only artifact and cannot appear in a codebase workflow asset`);
|
|
136
|
+
}
|
|
137
|
+
if (topLevelTreePaths.includes(NODE_MODULES_PATH)) {
|
|
138
|
+
return rejectPush(repoId, ref, `a committed top-level ${NODE_MODULES_PATH} directory is not allowed; the sidecar materializes dependencies from the resolved closure`);
|
|
139
|
+
}
|
|
140
|
+
// A `workflow.json` that also parses as a valid envelope makes the asset
|
|
141
|
+
// advertise two definitions; reject that. A `workflow.json` present but not a
|
|
142
|
+
// valid envelope is an ordinary source file and is allowed.
|
|
143
|
+
if (topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) {
|
|
144
|
+
const envelopeOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob);
|
|
145
|
+
if (envelopeOutcome.ok &&
|
|
146
|
+
!(workflowDefinitionEnvelopeSchema(envelopeOutcome.value) instanceof
|
|
147
|
+
type.errors)) {
|
|
148
|
+
return rejectPush(repoId, ref, `tree carries both ${PACKAGE_JSON_PATH} and an envelope-valid ${WORKFLOW_JSON_PATH}; a workflow asset must be a codebase or an envelope, not both`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const pkgOutcome = await readJSONBlob(PACKAGE_JSON_PATH, readBlob);
|
|
152
|
+
if (!pkgOutcome.ok) {
|
|
153
|
+
return rejectPush(repoId, ref, pkgOutcome.reason);
|
|
154
|
+
}
|
|
155
|
+
// `PackageJSON` does not declare `workspaces`, so it is read off the raw
|
|
156
|
+
// parsed value. A monorepo is a distinct codebase shape: the workflow lives
|
|
157
|
+
// in one member, selected at resolve time by `packageName`, so this gate does
|
|
158
|
+
// NOT descend into members or require a root `interchange.workflow`. It
|
|
159
|
+
// validates ROOT well-formedness only -- `workspaces` is an array of glob
|
|
160
|
+
// strings -- and defers per-member validation to the resolver, which re-reads
|
|
161
|
+
// every member and fails loud there (one enumeration owner, not two).
|
|
162
|
+
if (isJSONObject(pkgOutcome.value) && "workspaces" in pkgOutcome.value) {
|
|
163
|
+
const workspaces = pkgOutcome.value["workspaces"];
|
|
164
|
+
if (!Array.isArray(workspaces) ||
|
|
165
|
+
!workspaces.every((w) => typeof w === "string")) {
|
|
166
|
+
return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} "workspaces" must be an array of glob strings; the object form ({ packages, catalog, catalogs }) is not supported`);
|
|
167
|
+
}
|
|
168
|
+
return { ok: true };
|
|
169
|
+
}
|
|
170
|
+
// A pnpm monorepo declares its members in `pnpm-workspace.yaml`, not the
|
|
171
|
+
// package.json `workspaces` field, so a pnpm root has no `workspaces` and
|
|
172
|
+
// would fall through to the single-package check below. Reject that layout at
|
|
173
|
+
// the boundary with a clear message rather than letting it fail obscurely at
|
|
174
|
+
// resolve time (full pnpm support is tracked in INTR-461).
|
|
175
|
+
if (topLevelTreePaths.includes(PNPM_WORKSPACE_PATH)) {
|
|
176
|
+
return rejectPush(repoId, ref, `tree declares a ${PNPM_WORKSPACE_PATH}; the pnpm workspace layout is not supported -- declare members via a package.json "workspaces" array`);
|
|
177
|
+
}
|
|
178
|
+
const pkg = PackageJSON(pkgOutcome.value);
|
|
179
|
+
if (pkg instanceof type.errors) {
|
|
180
|
+
return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} failed validation: ${pkg.summary}`);
|
|
181
|
+
}
|
|
182
|
+
const entry = pkg.interchange?.workflow;
|
|
183
|
+
if (entry === undefined || entry === "") {
|
|
184
|
+
return rejectPush(repoId, ref, `${PACKAGE_JSON_PATH} must declare a non-empty "interchange.workflow" entry`);
|
|
185
|
+
}
|
|
186
|
+
if (!isContainedEntryPath(entry)) {
|
|
187
|
+
return rejectPush(repoId, ref, `"interchange.workflow" entry ${JSON.stringify(entry)} must be a package-relative path that does not escape the package`);
|
|
188
|
+
}
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
108
191
|
export const workflowKindHandler = {
|
|
109
192
|
kind: "workflow",
|
|
110
193
|
directoryPrefix: "assets/workflow",
|
|
111
194
|
async validatePush({ repoId, ref, topLevelTreePaths, readBlob, }) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
ok: false,
|
|
116
|
-
reason: `unexpected top-level entry ${JSON.stringify(entry)}; allowed: "${WORKFLOW_JSON_PATH}", "${CAPABILITY_DECLARATIONS_JSON_PATH}", "${WORKFLOW_GITIGNORE_PATH}"`,
|
|
117
|
-
};
|
|
118
|
-
}
|
|
195
|
+
if (topLevelTreePaths.includes(PACKAGE_JSON_PATH)) {
|
|
196
|
+
return validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob);
|
|
119
197
|
}
|
|
120
|
-
|
|
121
|
-
// incoherent: there is nothing for the deploy orchestrator to
|
|
122
|
-
// hydrate. Reject so the push surfaces the missing envelope at
|
|
123
|
-
// the boundary rather than at hydrate time.
|
|
124
|
-
if (!topLevelTreePaths.includes(WORKFLOW_JSON_PATH)) {
|
|
125
|
-
return {
|
|
126
|
-
ok: false,
|
|
127
|
-
reason: `tree is missing required ${WORKFLOW_JSON_PATH}`,
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
const workflowOutcome = await readJSONBlob(WORKFLOW_JSON_PATH, readBlob);
|
|
131
|
-
if (!workflowOutcome.ok) {
|
|
132
|
-
logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${workflowOutcome.reason}`;
|
|
133
|
-
return { ok: false, reason: workflowOutcome.reason };
|
|
134
|
-
}
|
|
135
|
-
const validated = workflowDefinitionEnvelopeSchema(workflowOutcome.value);
|
|
136
|
-
if (validated instanceof type.errors) {
|
|
137
|
-
const reason = `${WORKFLOW_JSON_PATH} failed validation: ${validated.summary}`;
|
|
138
|
-
logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
|
|
139
|
-
return { ok: false, reason };
|
|
140
|
-
}
|
|
141
|
-
if (topLevelTreePaths.includes(CAPABILITY_DECLARATIONS_JSON_PATH)) {
|
|
142
|
-
const capOutcome = await readJSONBlob(CAPABILITY_DECLARATIONS_JSON_PATH, readBlob);
|
|
143
|
-
if (!capOutcome.ok) {
|
|
144
|
-
logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${capOutcome.reason}`;
|
|
145
|
-
return { ok: false, reason: capOutcome.reason };
|
|
146
|
-
}
|
|
147
|
-
const capValidated = CapabilityDeclarationsObject(capOutcome.value);
|
|
148
|
-
if (capValidated instanceof type.errors) {
|
|
149
|
-
const reason = `${CAPABILITY_DECLARATIONS_JSON_PATH} must be a JSON object: ${capValidated.summary}`;
|
|
150
|
-
logger.debug `workflow validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${reason}`;
|
|
151
|
-
return { ok: false, reason };
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
return { ok: true };
|
|
198
|
+
return rejectPush(repoId, ref, `a workflow asset must be a codebase declaring a ${PACKAGE_JSON_PATH} with an "interchange.workflow" entry; the ${WORKFLOW_JSON_PATH} envelope form is no longer supported`);
|
|
155
199
|
},
|
|
156
200
|
onRefUpdated() {
|
|
157
|
-
// No cached index today. Consumers read the
|
|
158
|
-
//
|
|
159
|
-
// API at session time.
|
|
201
|
+
// No cached index today. Consumers read the asset's tree through the
|
|
202
|
+
// substrate's blob-read API at session time.
|
|
160
203
|
},
|
|
161
204
|
};
|
|
162
205
|
export const workflowAuthorize = (principal, repoId, ref, action) => {
|
|
@@ -256,6 +299,10 @@ export const workflowAuthorize = (principal, repoId, ref, action) => {
|
|
|
256
299
|
reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
|
|
257
300
|
};
|
|
258
301
|
}
|
|
302
|
+
// Fail closed on any kind not handled above. The tenant-level
|
|
303
|
+
// `workflow` principal kind (`@intx/types` principalKinds) is a
|
|
304
|
+
// grant owner, not a git/asset bearer, and never carries a workflow
|
|
305
|
+
// repo push here -- so it is intentionally left denied.
|
|
259
306
|
return {
|
|
260
307
|
allowed: false,
|
|
261
308
|
reason: `unknown principal kind: ${principal.kind}`,
|