@intx/hub-sessions 0.1.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/LICENSE +176 -0
- package/README.md +82 -1
- package/dist/agent-repo.d.ts +93 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +189 -0
- package/dist/asset-service.d.ts +104 -0
- package/dist/asset-service.js +267 -0
- package/dist/committed-source-tree.d.ts +10 -0
- package/dist/committed-source-tree.js +35 -0
- package/dist/credential-push.d.ts +33 -0
- package/dist/credential-push.js +109 -0
- package/dist/event-collector-registry.d.ts +20 -0
- package/dist/event-collector-registry.js +115 -0
- package/dist/event-collector.d.ts +39 -0
- package/dist/event-collector.js +365 -0
- package/dist/hub-session-lookups.d.ts +135 -0
- package/dist/hub-session-lookups.js +663 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +87 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +24 -0
- package/dist/package-registry-kind.d.ts +70 -0
- package/dist/package-registry-kind.js +260 -0
- package/dist/repo-store/index.d.ts +4 -0
- package/dist/repo-store/index.js +3 -0
- package/dist/repo-store/store.d.ts +41 -0
- package/dist/repo-store/store.js +1829 -0
- package/dist/repo-store/subscribe-kind.d.ts +56 -0
- package/dist/repo-store/subscribe-kind.js +144 -0
- package/dist/repo-store/types.d.ts +571 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +416 -0
- package/dist/session-service.js +1191 -0
- 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.d.ts +41 -0
- package/dist/skill-kind.js +292 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- 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 +37 -0
- package/dist/workflow-kind.js +310 -0
- package/dist/workflow-probe-gate.d.ts +214 -0
- package/dist/workflow-probe-gate.js +207 -0
- package/dist/workflow-run-event-log.d.ts +21 -0
- package/dist/workflow-run-event-log.js +51 -0
- package/dist/workflow-run-kind.d.ts +440 -0
- package/dist/workflow-run-kind.js +2916 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +153 -0
- 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 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +222 -0
- package/dist/ws/sidecar-events.js +72 -0
- package/dist/ws/sidecar-handler.d.ts +305 -0
- package/dist/ws/sidecar-handler.js +2537 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
- package/dist/ws/sidecar-token-authenticator.js +81 -0
- package/package.json +35 -12
- package/src/agent-repo.test.ts +0 -310
- package/src/agent-repo.ts +0 -165
- package/src/agent-state-kind.test.ts +0 -247
- package/src/agent-state-kind.ts +0 -204
- package/src/asset-service.test.ts +0 -540
- package/src/asset-service.ts +0 -378
- package/src/available-skills-stanza.test.ts +0 -87
- package/src/available-skills-stanza.ts +0 -47
- package/src/credential-push.ts +0 -65
- package/src/event-collector-registry.test.ts +0 -73
- package/src/event-collector-registry.ts +0 -171
- package/src/event-collector.test.ts +0 -1387
- package/src/event-collector.ts +0 -424
- package/src/hub-session-lookups.ts +0 -206
- package/src/hub-session-orchestrator.test.ts +0 -510
- package/src/hub-session-orchestrator.ts +0 -213
- package/src/index.ts +0 -78
- package/src/repo-store/index.ts +0 -15
- package/src/repo-store/store.test.ts +0 -1169
- package/src/repo-store/store.ts +0 -428
- package/src/repo-store/types.ts +0 -253
- package/src/session-service.test.ts +0 -895
- package/src/session-service.ts +0 -464
- package/src/skill-kind.test.ts +0 -599
- package/src/skill-kind.ts +0 -350
- package/src/ws/index.ts +0 -18
- package/src/ws/sidecar-events.test.ts +0 -96
- package/src/ws/sidecar-events.ts +0 -231
- package/src/ws/sidecar-handler.test.ts +0 -2217
- package/src/ws/sidecar-handler.ts +0 -1574
- package/tsconfig.json +0 -4
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type AuthorizeFn, type KindHandler } from "./repo-store/index.js";
|
|
2
|
+
export type WorkflowHubPrincipal = {
|
|
3
|
+
readonly kind: "hub";
|
|
4
|
+
};
|
|
5
|
+
export type WorkflowSidecarPrincipal = {
|
|
6
|
+
readonly kind: "sidecar";
|
|
7
|
+
readonly agentId: string;
|
|
8
|
+
};
|
|
9
|
+
export type WorkflowPrincipal = WorkflowHubPrincipal | WorkflowSidecarPrincipal;
|
|
10
|
+
export declare const WORKFLOW_JSON_PATH = "workflow.json";
|
|
11
|
+
export declare const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
|
|
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";
|
|
15
|
+
export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
16
|
+
id: string;
|
|
17
|
+
triggers: unknown[];
|
|
18
|
+
steps: Record<string, unknown>;
|
|
19
|
+
stepOrder: string[];
|
|
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
|
+
}[];
|
|
35
|
+
}, {}>;
|
|
36
|
+
export declare const workflowKindHandler: KindHandler;
|
|
37
|
+
export declare const workflowAuthorize: AuthorizeFn;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// KindHandler for the `workflow` asset kind.
|
|
2
|
+
//
|
|
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.
|
|
9
|
+
//
|
|
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.
|
|
17
|
+
//
|
|
18
|
+
// Authz:
|
|
19
|
+
// - hub principal: full access.
|
|
20
|
+
// - sidecar principal: read-only (createPack, resolveRef).
|
|
21
|
+
// - user principal: gated by bearer-token claims and the route
|
|
22
|
+
// layer's pre-resolved authz verdict, mirroring the convention
|
|
23
|
+
// used by skill assets.
|
|
24
|
+
import { type } from "arktype";
|
|
25
|
+
import { getLogger } from "@intx/log";
|
|
26
|
+
import { CredentialBinding, GrantRequirement } from "@intx/types";
|
|
27
|
+
import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
|
|
28
|
+
import { glob, repoActionToGrantVerb } from "@intx/hub-common";
|
|
29
|
+
import { UserPrincipal, } from "./repo-store/index.js";
|
|
30
|
+
const logger = getLogger(["hub-sessions", "workflow-kind"]);
|
|
31
|
+
export const WORKFLOW_JSON_PATH = "workflow.json";
|
|
32
|
+
export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
|
|
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
|
+
/**
|
|
37
|
+
* Structural arktype validator for the `workflow.json` envelope. The
|
|
38
|
+
* substrate checks the cross-cutting shape of `WorkflowDefinition`
|
|
39
|
+
* (presence and primitive type of `id`, `triggers`, `steps`,
|
|
40
|
+
* `stepOrder`) but does not re-derive `defineWorkflow`'s DAG-level
|
|
41
|
+
* validation here — primitive-level shape, default-input application,
|
|
42
|
+
* and `after`-ref resolution belong to the runtime layer that hydrates
|
|
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
|
+
*/
|
|
48
|
+
const StepsObject = type("Record<string, unknown>").narrow((value, ctx) => {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return ctx.mustBe("a JSON object, not an array");
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
});
|
|
54
|
+
const StateObject = type("Record<string, unknown>").narrow((value, ctx) => {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return ctx.mustBe("a JSON object, not an array");
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
});
|
|
60
|
+
export const workflowDefinitionEnvelopeSchema = type({
|
|
61
|
+
id: "string > 0",
|
|
62
|
+
triggers: "unknown[]",
|
|
63
|
+
steps: StepsObject,
|
|
64
|
+
stepOrder: "string[]",
|
|
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(),
|
|
81
|
+
}).onUndeclaredKey("ignore");
|
|
82
|
+
const SidecarPrincipal = type({
|
|
83
|
+
kind: "'sidecar'",
|
|
84
|
+
agentId: "string",
|
|
85
|
+
});
|
|
86
|
+
async function readJSONBlob(path, readBlob) {
|
|
87
|
+
let raw;
|
|
88
|
+
try {
|
|
89
|
+
raw = await readBlob(path);
|
|
90
|
+
}
|
|
91
|
+
catch (cause) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
reason: `${path} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const text = new TextDecoder().decode(raw);
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(text);
|
|
101
|
+
}
|
|
102
|
+
catch (cause) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: `${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, value: parsed };
|
|
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
|
+
}
|
|
191
|
+
export const workflowKindHandler = {
|
|
192
|
+
kind: "workflow",
|
|
193
|
+
directoryPrefix: "assets/workflow",
|
|
194
|
+
async validatePush({ repoId, ref, topLevelTreePaths, readBlob, }) {
|
|
195
|
+
if (topLevelTreePaths.includes(PACKAGE_JSON_PATH)) {
|
|
196
|
+
return validateWorkflowCodebasePush(repoId, ref, topLevelTreePaths, readBlob);
|
|
197
|
+
}
|
|
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`);
|
|
199
|
+
},
|
|
200
|
+
onRefUpdated() {
|
|
201
|
+
// No cached index today. Consumers read the asset's tree through the
|
|
202
|
+
// substrate's blob-read API at session time.
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
export const workflowAuthorize = (principal, repoId, ref, action) => {
|
|
206
|
+
if (repoId.kind !== "workflow") {
|
|
207
|
+
return {
|
|
208
|
+
allowed: false,
|
|
209
|
+
reason: `workflow authorize received non-workflow repo ${repoId.kind}/${repoId.id}`,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (principal.kind === "hub") {
|
|
213
|
+
return { allowed: true };
|
|
214
|
+
}
|
|
215
|
+
if (principal.kind === "sidecar") {
|
|
216
|
+
const parsed = SidecarPrincipal(principal);
|
|
217
|
+
if (parsed instanceof type.errors) {
|
|
218
|
+
return {
|
|
219
|
+
allowed: false,
|
|
220
|
+
reason: `sidecar principal is malformed: ${parsed.summary}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
switch (action) {
|
|
224
|
+
case "createPack":
|
|
225
|
+
case "resolveRef":
|
|
226
|
+
return { allowed: true };
|
|
227
|
+
case "init":
|
|
228
|
+
case "writeTree":
|
|
229
|
+
case "receivePack":
|
|
230
|
+
return {
|
|
231
|
+
allowed: false,
|
|
232
|
+
reason: `sidecars may only read workflow assets, not ${action}`,
|
|
233
|
+
};
|
|
234
|
+
default: {
|
|
235
|
+
const _exhaustive = action;
|
|
236
|
+
return {
|
|
237
|
+
allowed: false,
|
|
238
|
+
reason: `unhandled action: ${String(_exhaustive)}`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (principal.kind === "user") {
|
|
244
|
+
// The route layer has already pre-resolved the grant verdict and
|
|
245
|
+
// attached it as `authz`. The substrate does NOT re-query the
|
|
246
|
+
// grant store here; it (a) checks the bearer-token's claims
|
|
247
|
+
// bound the requested (ref, action) and have not expired, and
|
|
248
|
+
// (b) sanity-checks that the pre-resolved verdict targets this
|
|
249
|
+
// exact resource and grant verb. Both gates must pass before the
|
|
250
|
+
// verdict's `effect` is honoured.
|
|
251
|
+
const parsed = UserPrincipal(principal);
|
|
252
|
+
if (parsed instanceof type.errors) {
|
|
253
|
+
return {
|
|
254
|
+
allowed: false,
|
|
255
|
+
reason: `user principal is malformed: ${parsed.summary}`,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (!parsed.tokenClaims.actions.includes(action)) {
|
|
259
|
+
return {
|
|
260
|
+
allowed: false,
|
|
261
|
+
reason: `token does not grant action ${action}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
// `ref === "*"` is the substrate's sentinel for the bulk read
|
|
265
|
+
// performed by `listRefs`. Per-ref filtering is the advertise-refs
|
|
266
|
+
// layer's responsibility, so the bulk read is gated on action and
|
|
267
|
+
// expiry alone.
|
|
268
|
+
if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
|
|
269
|
+
return {
|
|
270
|
+
allowed: false,
|
|
271
|
+
reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (Date.now() >= parsed.tokenClaims.expiresAt) {
|
|
275
|
+
return {
|
|
276
|
+
allowed: false,
|
|
277
|
+
reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const expectedResource = `asset:${repoId.id}`;
|
|
281
|
+
if (parsed.authz.resource !== expectedResource) {
|
|
282
|
+
return {
|
|
283
|
+
allowed: false,
|
|
284
|
+
reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const expectedGrantVerb = repoActionToGrantVerb(action);
|
|
288
|
+
if (parsed.authz.grantVerb !== expectedGrantVerb) {
|
|
289
|
+
return {
|
|
290
|
+
allowed: false,
|
|
291
|
+
reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (parsed.authz.effect === "allow") {
|
|
295
|
+
return { allowed: true };
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
allowed: false,
|
|
299
|
+
reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
|
|
300
|
+
};
|
|
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.
|
|
306
|
+
return {
|
|
307
|
+
allowed: false,
|
|
308
|
+
reason: `unknown principal kind: ${principal.kind}`,
|
|
309
|
+
};
|
|
310
|
+
};
|