@intx/hub-sessions 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +3 -5
  2. package/dist/agent-repo.d.ts +9 -5
  3. package/dist/agent-repo.js +2 -2
  4. package/dist/agent-state-kind.js +4 -0
  5. package/dist/asset-service.d.ts +1 -20
  6. package/dist/asset-service.js +9 -91
  7. package/dist/committed-source-tree.d.ts +10 -0
  8. package/dist/committed-source-tree.js +35 -0
  9. package/dist/credential-push.d.ts +7 -6
  10. package/dist/credential-push.js +42 -18
  11. package/dist/event-collector-registry.d.ts +1 -1
  12. package/dist/event-collector-registry.js +4 -4
  13. package/dist/event-collector.d.ts +1 -1
  14. package/dist/event-collector.js +10 -2
  15. package/dist/hub-session-lookups.d.ts +125 -7
  16. package/dist/hub-session-lookups.js +539 -80
  17. package/dist/hub-session-orchestrator.js +14 -49
  18. package/dist/index.d.ts +17 -8
  19. package/dist/index.js +14 -6
  20. package/dist/repo-store/index.d.ts +1 -1
  21. package/dist/repo-store/store.d.ts +1 -1
  22. package/dist/repo-store/store.js +138 -1
  23. package/dist/repo-store/subscribe-kind.d.ts +6 -3
  24. package/dist/repo-store/subscribe-kind.js +42 -77
  25. package/dist/repo-store/types.d.ts +94 -6
  26. package/dist/session-service.d.ts +277 -96
  27. package/dist/session-service.js +741 -547
  28. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  29. package/dist/sidecar-allocation/contracts.js +21 -0
  30. package/dist/sidecar-allocation/index.d.ts +4 -0
  31. package/dist/sidecar-allocation/index.js +3 -0
  32. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  33. package/dist/sidecar-allocation/placement-policy.js +21 -0
  34. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  35. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  36. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  37. package/dist/sidecar-allocation/reconciler.js +431 -0
  38. package/dist/skill-kind.js +4 -0
  39. package/dist/substrate.d.ts +3 -3
  40. package/dist/substrate.js +1 -1
  41. package/dist/workflow-allocation-service.d.ts +58 -0
  42. package/dist/workflow-allocation-service.js +239 -0
  43. package/dist/workflow-closure-resolution.d.ts +106 -0
  44. package/dist/workflow-closure-resolution.js +123 -0
  45. package/dist/workflow-definition-ensure.d.ts +24 -0
  46. package/dist/workflow-definition-ensure.js +75 -0
  47. package/dist/workflow-dispatch-service.d.ts +40 -0
  48. package/dist/workflow-dispatch-service.js +146 -0
  49. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  50. package/dist/workflow-dispatch-settlement.js +140 -0
  51. package/dist/workflow-kind.d.ts +17 -1
  52. package/dist/workflow-kind.js +127 -80
  53. package/dist/workflow-probe-gate.d.ts +214 -0
  54. package/dist/workflow-probe-gate.js +207 -0
  55. package/dist/workflow-run-kind.d.ts +128 -14
  56. package/dist/workflow-run-kind.js +353 -83
  57. package/dist/workflow-run-reader.d.ts +1 -1
  58. package/dist/workflow-run-reader.js +3 -7
  59. package/dist/workflow-run-restore.d.ts +15 -0
  60. package/dist/workflow-run-restore.js +26 -0
  61. package/dist/workflow-source-closure.d.ts +35 -0
  62. package/dist/workflow-source-closure.js +342 -0
  63. package/dist/ws/index.d.ts +3 -3
  64. package/dist/ws/index.js +1 -1
  65. package/dist/ws/sidecar-events.d.ts +100 -12
  66. package/dist/ws/sidecar-events.js +2 -0
  67. package/dist/ws/sidecar-handler.d.ts +128 -7
  68. package/dist/ws/sidecar-handler.js +1069 -135
  69. package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
  70. package/dist/ws/sidecar-token-authenticator.js +64 -7
  71. package/package.json +14 -13
  72. package/dist/available-skills-stanza.d.ts +0 -21
  73. package/dist/available-skills-stanza.js +0 -32
@@ -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
+ }
@@ -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 WORKFLOW_GITIGNORE_PATH = ".gitignore";
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;
@@ -1,18 +1,19 @@
1
1
  // KindHandler for the `workflow` asset kind.
2
2
  //
3
- // A workflow asset is a git repo that holds a single `WorkflowDefinition`
4
- // envelope plus its capability-walk output. The deploy tree shape is:
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
- // - `workflow.json` the full `WorkflowDefinition` envelope. The
7
- // content is parsed and structurally validated at push time; deeper
8
- // primitive-shape and DAG validation belongs to the runtime layer
9
- // that instantiates the definition (`defineWorkflow`).
10
- // - `capability-declarations.json` the per-step capability-walk
11
- // output. Content shape is owned by the capability-walk module; the
12
- // substrate only verifies the file parses as a JSON object.
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 WORKFLOW_GITIGNORE_PATH = ".gitignore";
31
- const ALLOWED_TOP_LEVEL = new Set([
32
- WORKFLOW_JSON_PATH,
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. Push-time validation rejects the obvious wrongs
44
- * (missing top-level fields, wrong primitive types) so a tree that
45
- * could not possibly hydrate into a `WorkflowDefinition` never reaches
46
- * the deploy ref.
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
- for (const entry of topLevelTreePaths) {
113
- if (!ALLOWED_TOP_LEVEL.has(entry)) {
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
- // A workflow asset without `workflow.json` is structurally
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 workflow.json and
158
- // capability-declarations.json through the substrate's blob-read
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}`,