@intx/hub-sessions 0.1.2 → 0.2.2

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 (87) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +84 -1
  3. package/dist/agent-repo.d.ts +89 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +185 -0
  7. package/dist/asset-service.d.ts +123 -0
  8. package/dist/asset-service.js +349 -0
  9. package/dist/available-skills-stanza.d.ts +21 -0
  10. package/dist/available-skills-stanza.js +32 -0
  11. package/dist/credential-push.d.ts +32 -0
  12. package/dist/credential-push.js +85 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +357 -0
  17. package/dist/hub-session-lookups.d.ts +17 -0
  18. package/dist/hub-session-lookups.js +204 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +122 -0
  21. package/dist/index.d.ts +18 -0
  22. package/dist/index.js +16 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1692 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +53 -0
  30. package/dist/repo-store/subscribe-kind.js +179 -0
  31. package/dist/repo-store/types.d.ts +483 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +235 -0
  34. package/dist/session-service.js +997 -0
  35. package/dist/skill-kind.d.ts +41 -0
  36. package/dist/skill-kind.js +288 -0
  37. package/dist/substrate.d.ts +8 -0
  38. package/dist/substrate.js +21 -0
  39. package/dist/workflow-kind.d.ts +21 -0
  40. package/dist/workflow-kind.js +263 -0
  41. package/dist/workflow-run-event-log.d.ts +21 -0
  42. package/dist/workflow-run-event-log.js +51 -0
  43. package/dist/workflow-run-kind.d.ts +326 -0
  44. package/dist/workflow-run-kind.js +2646 -0
  45. package/dist/workflow-run-reader.d.ts +47 -0
  46. package/dist/workflow-run-reader.js +157 -0
  47. package/dist/ws/index.d.ts +3 -0
  48. package/dist/ws/index.js +3 -0
  49. package/dist/ws/sidecar-events.d.ts +134 -0
  50. package/dist/ws/sidecar-events.js +70 -0
  51. package/dist/ws/sidecar-handler.d.ts +184 -0
  52. package/dist/ws/sidecar-handler.js +1603 -0
  53. package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
  54. package/dist/ws/sidecar-token-authenticator.js +24 -0
  55. package/package.json +34 -12
  56. package/src/agent-repo.test.ts +0 -310
  57. package/src/agent-repo.ts +0 -165
  58. package/src/agent-state-kind.test.ts +0 -247
  59. package/src/agent-state-kind.ts +0 -204
  60. package/src/asset-service.test.ts +0 -540
  61. package/src/asset-service.ts +0 -378
  62. package/src/available-skills-stanza.test.ts +0 -87
  63. package/src/available-skills-stanza.ts +0 -47
  64. package/src/credential-push.ts +0 -65
  65. package/src/event-collector-registry.test.ts +0 -73
  66. package/src/event-collector-registry.ts +0 -171
  67. package/src/event-collector.test.ts +0 -1387
  68. package/src/event-collector.ts +0 -424
  69. package/src/hub-session-lookups.ts +0 -206
  70. package/src/hub-session-orchestrator.test.ts +0 -510
  71. package/src/hub-session-orchestrator.ts +0 -213
  72. package/src/index.ts +0 -78
  73. package/src/repo-store/index.ts +0 -15
  74. package/src/repo-store/store.test.ts +0 -1169
  75. package/src/repo-store/store.ts +0 -428
  76. package/src/repo-store/types.ts +0 -253
  77. package/src/session-service.test.ts +0 -895
  78. package/src/session-service.ts +0 -464
  79. package/src/skill-kind.test.ts +0 -599
  80. package/src/skill-kind.ts +0 -350
  81. package/src/ws/index.ts +0 -18
  82. package/src/ws/sidecar-events.test.ts +0 -96
  83. package/src/ws/sidecar-events.ts +0 -231
  84. package/src/ws/sidecar-handler.test.ts +0 -2217
  85. package/src/ws/sidecar-handler.ts +0 -1574
  86. package/tsconfig.json +0 -4
  87. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,41 @@
1
+ import { type AuthorizeFn, type KindHandler } from "./repo-store/index.js";
2
+ export type SkillHubPrincipal = {
3
+ readonly kind: "hub";
4
+ };
5
+ export type SkillSidecarPrincipal = {
6
+ readonly kind: "sidecar";
7
+ readonly agentId: string;
8
+ };
9
+ export type SkillPrincipal = SkillHubPrincipal | SkillSidecarPrincipal;
10
+ /**
11
+ * arktype schema for the SKILL.md frontmatter. Required fields are
12
+ * `name` and `description`; the Claude Code superset of optional fields
13
+ * (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
14
+ * via `onUndeclaredKey("ignore")` but not enforced.
15
+ *
16
+ * The forbidden-name narrow rejects `"anthropic"` and `"claude"`
17
+ * because those values are reserved by the upstream agentskills.io
18
+ * spec for vendor-owned skill packs.
19
+ */
20
+ export declare const skillFrontmatterSchema: import("arktype/internal/variants/object.ts").ObjectType<{
21
+ name: string;
22
+ description: string;
23
+ }, {}>;
24
+ export type SkillFrontmatter = typeof skillFrontmatterSchema.infer;
25
+ export type SkillIndexEntry = {
26
+ /** Equal to the containing directory name and to `frontmatter.name`. */
27
+ name: string;
28
+ description: string;
29
+ /** Full parsed frontmatter, including any accepted optional fields. */
30
+ frontmatter: Record<string, unknown>;
31
+ /** Path of the skill subdirectory relative to the asset mount, with trailing slash. */
32
+ workspaceSubpath: string;
33
+ };
34
+ /**
35
+ * Returns the parsed skill index for `(assetId, ref)`, or an empty
36
+ * array if no index has been populated yet. The index is refreshed by
37
+ * the kind handler's `onRefUpdated` hook after each successful write.
38
+ */
39
+ export declare function getSkillIndex(assetId: string, ref: string): SkillIndexEntry[];
40
+ export declare const skillKindHandler: KindHandler;
41
+ export declare const skillAuthorize: AuthorizeFn;
@@ -0,0 +1,288 @@
1
+ import { type } from "arktype";
2
+ import { getLogger } from "@intx/log";
3
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
4
+ import { UserPrincipal, } from "./repo-store/index.js";
5
+ const logger = getLogger(["hub-sessions", "skill-kind"]);
6
+ /**
7
+ * arktype schema for the SKILL.md frontmatter. Required fields are
8
+ * `name` and `description`; the Claude Code superset of optional fields
9
+ * (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
10
+ * via `onUndeclaredKey("ignore")` but not enforced.
11
+ *
12
+ * The forbidden-name narrow rejects `"anthropic"` and `"claude"`
13
+ * because those values are reserved by the upstream agentskills.io
14
+ * spec for vendor-owned skill packs.
15
+ */
16
+ export const skillFrontmatterSchema = type({
17
+ name: type(/^[a-z0-9]+(-[a-z0-9]+)*$/)
18
+ .and("string<=64")
19
+ .narrow((n, ctx) => {
20
+ if (n === "anthropic" || n === "claude") {
21
+ return ctx.mustBe(`not the reserved name "anthropic" or "claude"`);
22
+ }
23
+ return true;
24
+ }),
25
+ description: type("1 <= string <= 1024").and(type(/^(?!.*<[^>]+>).*$/s)),
26
+ }).onUndeclaredKey("ignore");
27
+ function cacheKey(assetId, ref) {
28
+ return `${assetId}\u0000${ref}`;
29
+ }
30
+ const skillIndex = new Map();
31
+ const pendingIndex = new Map();
32
+ /**
33
+ * Returns the parsed skill index for `(assetId, ref)`, or an empty
34
+ * array if no index has been populated yet. The index is refreshed by
35
+ * the kind handler's `onRefUpdated` hook after each successful write.
36
+ */
37
+ export function getSkillIndex(assetId, ref) {
38
+ return skillIndex.get(cacheKey(assetId, ref)) ?? [];
39
+ }
40
+ const FRONTMATTER_DELIMITER = "---";
41
+ const YamlMapping = type("Record<string, unknown>");
42
+ function parseSkillMd(body) {
43
+ const lines = body.split(/\r?\n/);
44
+ if (lines[0] !== FRONTMATTER_DELIMITER) {
45
+ throw new Error("SKILL.md is missing YAML frontmatter delimiter");
46
+ }
47
+ let endIdx = -1;
48
+ for (let i = 1; i < lines.length; i += 1) {
49
+ if (lines[i] === FRONTMATTER_DELIMITER) {
50
+ endIdx = i;
51
+ break;
52
+ }
53
+ }
54
+ if (endIdx === -1) {
55
+ throw new Error("SKILL.md frontmatter has no closing delimiter");
56
+ }
57
+ const yamlText = lines.slice(1, endIdx).join("\n");
58
+ const parsed = Bun.YAML.parse(yamlText);
59
+ const validated = YamlMapping(parsed);
60
+ if (validated instanceof type.errors) {
61
+ throw new Error(`SKILL.md frontmatter must be a YAML mapping: ${validated.summary}`);
62
+ }
63
+ return { frontmatter: validated };
64
+ }
65
+ async function parseSkillEntry(subdir, readBlob) {
66
+ const skillPath = `${subdir}/SKILL.md`;
67
+ let raw;
68
+ try {
69
+ raw = await readBlob(skillPath);
70
+ }
71
+ catch (cause) {
72
+ return {
73
+ ok: false,
74
+ reason: `skill ${subdir} is missing SKILL.md: ${cause instanceof Error ? cause.message : String(cause)}`,
75
+ };
76
+ }
77
+ const body = new TextDecoder().decode(raw);
78
+ let parsed;
79
+ try {
80
+ parsed = parseSkillMd(body);
81
+ }
82
+ catch (cause) {
83
+ return {
84
+ ok: false,
85
+ reason: `skill ${subdir} frontmatter parse failed: ${cause instanceof Error ? cause.message : String(cause)}`,
86
+ };
87
+ }
88
+ const result = skillFrontmatterSchema(parsed.frontmatter);
89
+ if (result instanceof type.errors) {
90
+ return {
91
+ ok: false,
92
+ reason: `skill ${subdir} frontmatter is invalid: ${result.summary}`,
93
+ };
94
+ }
95
+ if (result.name !== subdir) {
96
+ return {
97
+ ok: false,
98
+ reason: `skill ${subdir} frontmatter.name ${JSON.stringify(result.name)} does not match directory name`,
99
+ };
100
+ }
101
+ return {
102
+ ok: true,
103
+ entry: {
104
+ name: result.name,
105
+ description: result.description,
106
+ frontmatter: parsed.frontmatter,
107
+ workspaceSubpath: `${subdir}/`,
108
+ },
109
+ };
110
+ }
111
+ async function buildSkillIndex(topLevelTreePaths, readBlob, listDir) {
112
+ const entries = [];
113
+ // Sort so the index ordering is deterministic across reads.
114
+ const candidates = [...topLevelTreePaths].sort();
115
+ for (const candidate of candidates) {
116
+ // Skill subdirectories are arbitrarily named (`<skill-name>/`), so
117
+ // there is no enumerable allowlist of names the way the workflow,
118
+ // package-registry, and agent-state handlers use. The structural
119
+ // distinction is tree-vs-blob: a top-level tree entry is a skill
120
+ // subdir, a top-level blob (e.g. `.gitignore` seeded by the
121
+ // genesis init) is not.
122
+ //
123
+ // Probe via `listDir`. The substrate's two implementations
124
+ // disagree on the not-a-tree signal — the receivePack closures
125
+ // throw, the writeTree closures return an empty array — so this
126
+ // probe accepts either signal as "not a directory" and skips the
127
+ // entry. Git does not store empty trees, so an empty `listDir`
128
+ // result is equivalent to "this name is not a directory in the
129
+ // prospective tree".
130
+ let children;
131
+ try {
132
+ children = await listDir(candidate);
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ if (children.length === 0)
138
+ continue;
139
+ const outcome = await parseSkillEntry(candidate, readBlob);
140
+ if (!outcome.ok) {
141
+ return { ok: false, reason: outcome.reason };
142
+ }
143
+ entries.push(outcome.entry);
144
+ }
145
+ return { ok: true, entries };
146
+ }
147
+ export const skillKindHandler = {
148
+ kind: "skill",
149
+ directoryPrefix: "assets/skill",
150
+ async validatePush({ repoId, ref, topLevelTreePaths, readBlob, listDir, }) {
151
+ // Drop any staged entry from a previous attempt first. The
152
+ // substrate calls validatePush before advancing the ref, so a prior
153
+ // validation that was accepted but never followed by onRefUpdated
154
+ // (e.g. the commit step threw after validation succeeded) would
155
+ // otherwise leave a stale entry that a later rejected attempt
156
+ // would silently inherit.
157
+ const key = cacheKey(repoId.id, ref);
158
+ pendingIndex.delete(key);
159
+ const result = await buildSkillIndex(topLevelTreePaths, readBlob, listDir);
160
+ if (!result.ok) {
161
+ logger.debug `skill validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${result.reason}`;
162
+ return { ok: false, reason: result.reason };
163
+ }
164
+ // Stage the parsed index against (repoId.id, ref). The substrate
165
+ // calls onRefUpdated immediately after the ref is advanced and we
166
+ // promote the staged entry into the live cache there.
167
+ pendingIndex.set(key, result.entries);
168
+ return { ok: true };
169
+ },
170
+ onRefUpdated({ repoId, ref }) {
171
+ const key = cacheKey(repoId.id, ref);
172
+ const staged = pendingIndex.get(key);
173
+ if (staged === undefined) {
174
+ throw new Error(`skillKindHandler.onRefUpdated: no validated tree pending for ${repoId.id} @ ${ref}`);
175
+ }
176
+ pendingIndex.delete(key);
177
+ skillIndex.set(key, staged);
178
+ },
179
+ };
180
+ const SidecarPrincipal = type({
181
+ kind: "'sidecar'",
182
+ agentId: "string",
183
+ });
184
+ export const skillAuthorize = (principal, repoId, ref, action) => {
185
+ if (repoId.kind !== "skill") {
186
+ return {
187
+ allowed: false,
188
+ reason: `skill authorize received non-skill repo ${repoId.kind}/${repoId.id}`,
189
+ };
190
+ }
191
+ if (principal.kind === "hub") {
192
+ return { allowed: true };
193
+ }
194
+ if (principal.kind === "sidecar") {
195
+ const parsed = SidecarPrincipal(principal);
196
+ if (parsed instanceof type.errors) {
197
+ return {
198
+ allowed: false,
199
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
200
+ };
201
+ }
202
+ switch (action) {
203
+ case "createPack":
204
+ case "resolveRef":
205
+ return { allowed: true };
206
+ case "init":
207
+ case "writeTree":
208
+ case "receivePack":
209
+ return {
210
+ allowed: false,
211
+ reason: `sidecars may only read skill assets, not ${action}`,
212
+ };
213
+ default: {
214
+ const _exhaustive = action;
215
+ return {
216
+ allowed: false,
217
+ reason: `unhandled action: ${String(_exhaustive)}`,
218
+ };
219
+ }
220
+ }
221
+ }
222
+ if (principal.kind === "user") {
223
+ // The route layer has already pre-resolved the grant verdict and
224
+ // attached it as `authz`. The substrate does NOT re-query the
225
+ // grant store here; it (a) checks the bearer-token's claims
226
+ // bound the requested (ref, action) and have not expired, and
227
+ // (b) sanity-checks that the pre-resolved verdict targets this
228
+ // exact resource and grant verb. Both gates must pass before the
229
+ // verdict's `effect` is honoured.
230
+ const parsed = UserPrincipal(principal);
231
+ if (parsed instanceof type.errors) {
232
+ return {
233
+ allowed: false,
234
+ reason: `user principal is malformed: ${parsed.summary}`,
235
+ };
236
+ }
237
+ if (!parsed.tokenClaims.actions.includes(action)) {
238
+ return {
239
+ allowed: false,
240
+ reason: `token does not grant action ${action}`,
241
+ };
242
+ }
243
+ // `ref === "*"` is the substrate's sentinel for a bulk read used
244
+ // by `listRefs`. The advertise-refs layer applies the per-ref
245
+ // refPattern filter on the listing it returns, so gating the bulk
246
+ // read on refPattern here would prevent any token with a
247
+ // narrow-pattern (e.g. `refs/heads/main`) from listing refs at
248
+ // all. Only the action and expiry are enforced for the bulk read;
249
+ // every other call site passes a concrete ref and is gated below.
250
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
251
+ return {
252
+ allowed: false,
253
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
254
+ };
255
+ }
256
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
257
+ return {
258
+ allowed: false,
259
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
260
+ };
261
+ }
262
+ const expectedResource = `asset:${repoId.id}`;
263
+ if (parsed.authz.resource !== expectedResource) {
264
+ return {
265
+ allowed: false,
266
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
267
+ };
268
+ }
269
+ const expectedGrantVerb = repoActionToGrantVerb(action);
270
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
271
+ return {
272
+ allowed: false,
273
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
274
+ };
275
+ }
276
+ if (parsed.authz.effect === "allow") {
277
+ return { allowed: true };
278
+ }
279
+ return {
280
+ allowed: false,
281
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
282
+ };
283
+ }
284
+ return {
285
+ allowed: false,
286
+ reason: `unknown principal kind: ${principal.kind}`,
287
+ };
288
+ };
@@ -0,0 +1,8 @@
1
+ export { DEFAULT_CONSUMED_RETENTION_MS, dequeueToProcessing, enqueueInbox, markConsumed, readOwnedMessageIds, readProcessingEntry, replayProcessingToInbox, WORKFLOW_RUN_AGENT_STATE_PREFIX, } from "./workflow-run-kind.js";
2
+ export type { WorkflowRunSupervisorPrincipal, WorkflowRunWorkflowProcessPrincipal, DequeueToProcessingResult, EnqueueInboxArgs, EnqueueInboxResult, MarkConsumedArgs, MarkConsumedResult, ReplayProcessingToInboxOpts, ReplayProcessingToInboxResult, } from "./workflow-run-kind.js";
3
+ export { encodeCombinedEventLog, splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
4
+ export { workflowDefinitionEnvelopeSchema } from "./workflow-kind.js";
5
+ export { subscribeKind } from "./repo-store/subscribe-kind.js";
6
+ export type { SubscribeKindEntry } from "./repo-store/subscribe-kind.js";
7
+ export { createAgentRepoStore } from "./agent-repo.js";
8
+ export type { Principal, RepoId, RepoStore, WriteResult, InitRepoOpts, NewlyTerminalRun, } from "./repo-store/types.js";
@@ -0,0 +1,21 @@
1
+ // The db-free substrate layer of this package.
2
+ //
3
+ // `@intx/hub-sessions` holds two layers: the git-backed repo-store substrate
4
+ // (the `RepoStore`, the workflow-run claim-check and event-log primitives, the
5
+ // kind-handler protocol) and the db-backed control-plane services (sessions,
6
+ // credential push, the websocket layer). The substrate layer never imports
7
+ // `@intx/db`; the services layer does. The package barrel (`index.ts`)
8
+ // re-exports both, so importing any symbol through it pulls drizzle and the
9
+ // rest of the hub data layer into module-evaluation.
10
+ //
11
+ // This entry exposes only the substrate layer, so any consumer that needs
12
+ // repo/substrate access without the control-plane services -- for example a
13
+ // process that reads and writes the agent-state and workflow-run repos but
14
+ // runs nowhere near a database -- can import it without dragging `@intx/db`
15
+ // into its boot graph. Every symbol re-exported here is defined in a db-free
16
+ // module. The barrel still re-exports all of these for hub-side consumers.
17
+ export { DEFAULT_CONSUMED_RETENTION_MS, dequeueToProcessing, enqueueInbox, markConsumed, readOwnedMessageIds, readProcessingEntry, replayProcessingToInbox, WORKFLOW_RUN_AGENT_STATE_PREFIX, } from "./workflow-run-kind.js";
18
+ export { encodeCombinedEventLog, splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
19
+ export { workflowDefinitionEnvelopeSchema } from "./workflow-kind.js";
20
+ export { subscribeKind } from "./repo-store/subscribe-kind.js";
21
+ export { createAgentRepoStore } from "./agent-repo.js";
@@ -0,0 +1,21 @@
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 WORKFLOW_GITIGNORE_PATH = ".gitignore";
13
+ export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/variants/object.ts").ObjectType<{
14
+ id: string;
15
+ triggers: unknown[];
16
+ steps: Record<string, unknown>;
17
+ stepOrder: string[];
18
+ state?: Record<string, unknown>;
19
+ }, {}>;
20
+ export declare const workflowKindHandler: KindHandler;
21
+ export declare const workflowAuthorize: AuthorizeFn;
@@ -0,0 +1,263 @@
1
+ // KindHandler for the `workflow` asset kind.
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:
5
+ //
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.
16
+ //
17
+ // Authz:
18
+ // - hub principal: full access.
19
+ // - sidecar principal: read-only (createPack, resolveRef).
20
+ // - user principal: gated by bearer-token claims and the route
21
+ // layer's pre-resolved authz verdict, mirroring the convention
22
+ // used by skill assets.
23
+ import { type } from "arktype";
24
+ import { getLogger } from "@intx/log";
25
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
26
+ import { UserPrincipal, } from "./repo-store/index.js";
27
+ const logger = getLogger(["hub-sessions", "workflow-kind"]);
28
+ export const WORKFLOW_JSON_PATH = "workflow.json";
29
+ 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
+ ]);
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. 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.
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
+ }).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
+ const SidecarPrincipal = type({
81
+ kind: "'sidecar'",
82
+ agentId: "string",
83
+ });
84
+ async function readJSONBlob(path, readBlob) {
85
+ let raw;
86
+ try {
87
+ raw = await readBlob(path);
88
+ }
89
+ catch (cause) {
90
+ return {
91
+ ok: false,
92
+ reason: `${path} could not be read from the tree: ${cause instanceof Error ? cause.message : String(cause)}`,
93
+ };
94
+ }
95
+ const text = new TextDecoder().decode(raw);
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ }
100
+ catch (cause) {
101
+ return {
102
+ ok: false,
103
+ reason: `${path} is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
104
+ };
105
+ }
106
+ return { ok: true, value: parsed };
107
+ }
108
+ export const workflowKindHandler = {
109
+ kind: "workflow",
110
+ directoryPrefix: "assets/workflow",
111
+ 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
+ }
119
+ }
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 };
155
+ },
156
+ 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.
160
+ },
161
+ };
162
+ export const workflowAuthorize = (principal, repoId, ref, action) => {
163
+ if (repoId.kind !== "workflow") {
164
+ return {
165
+ allowed: false,
166
+ reason: `workflow authorize received non-workflow repo ${repoId.kind}/${repoId.id}`,
167
+ };
168
+ }
169
+ if (principal.kind === "hub") {
170
+ return { allowed: true };
171
+ }
172
+ if (principal.kind === "sidecar") {
173
+ const parsed = SidecarPrincipal(principal);
174
+ if (parsed instanceof type.errors) {
175
+ return {
176
+ allowed: false,
177
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
178
+ };
179
+ }
180
+ switch (action) {
181
+ case "createPack":
182
+ case "resolveRef":
183
+ return { allowed: true };
184
+ case "init":
185
+ case "writeTree":
186
+ case "receivePack":
187
+ return {
188
+ allowed: false,
189
+ reason: `sidecars may only read workflow assets, not ${action}`,
190
+ };
191
+ default: {
192
+ const _exhaustive = action;
193
+ return {
194
+ allowed: false,
195
+ reason: `unhandled action: ${String(_exhaustive)}`,
196
+ };
197
+ }
198
+ }
199
+ }
200
+ if (principal.kind === "user") {
201
+ // The route layer has already pre-resolved the grant verdict and
202
+ // attached it as `authz`. The substrate does NOT re-query the
203
+ // grant store here; it (a) checks the bearer-token's claims
204
+ // bound the requested (ref, action) and have not expired, and
205
+ // (b) sanity-checks that the pre-resolved verdict targets this
206
+ // exact resource and grant verb. Both gates must pass before the
207
+ // verdict's `effect` is honoured.
208
+ const parsed = UserPrincipal(principal);
209
+ if (parsed instanceof type.errors) {
210
+ return {
211
+ allowed: false,
212
+ reason: `user principal is malformed: ${parsed.summary}`,
213
+ };
214
+ }
215
+ if (!parsed.tokenClaims.actions.includes(action)) {
216
+ return {
217
+ allowed: false,
218
+ reason: `token does not grant action ${action}`,
219
+ };
220
+ }
221
+ // `ref === "*"` is the substrate's sentinel for the bulk read
222
+ // performed by `listRefs`. Per-ref filtering is the advertise-refs
223
+ // layer's responsibility, so the bulk read is gated on action and
224
+ // expiry alone.
225
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
226
+ return {
227
+ allowed: false,
228
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
229
+ };
230
+ }
231
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
232
+ return {
233
+ allowed: false,
234
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
235
+ };
236
+ }
237
+ const expectedResource = `asset:${repoId.id}`;
238
+ if (parsed.authz.resource !== expectedResource) {
239
+ return {
240
+ allowed: false,
241
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
242
+ };
243
+ }
244
+ const expectedGrantVerb = repoActionToGrantVerb(action);
245
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
246
+ return {
247
+ allowed: false,
248
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
249
+ };
250
+ }
251
+ if (parsed.authz.effect === "allow") {
252
+ return { allowed: true };
253
+ }
254
+ return {
255
+ allowed: false,
256
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
257
+ };
258
+ }
259
+ return {
260
+ allowed: false,
261
+ reason: `unknown principal kind: ${principal.kind}`,
262
+ };
263
+ };
@@ -0,0 +1,21 @@
1
+ /** Filename of a run's combined event log, a sibling of its `events/` dir. */
2
+ export declare const WORKFLOW_RUN_EVENTS_FILE = "events.jsonl";
3
+ /**
4
+ * Split a combined event-log file's content into the per-event JSON texts it
5
+ * holds, in file order. Each non-empty line is the verbatim text of what was
6
+ * an `events/<seq>.json` blob; the trailing newline yields no extra entry.
7
+ * Event JSON never contains a literal newline (JSON escapes them), so a line
8
+ * split is a faithful inverse of the encode side.
9
+ */
10
+ export declare function splitCombinedEventLog(content: string): string[];
11
+ /**
12
+ * Join per-event blobs (in seq order) into a combined event-log file: each
13
+ * blob's exact bytes followed by a newline. Operating on bytes -- not
14
+ * decoded strings -- keeps the sealed file a *verbatim* concatenation of
15
+ * the per-event blobs, which matters because each event is signed over its
16
+ * own bytes; a decode/re-encode round-trip could alter them. This is the
17
+ * single source of the combined-file byte layout shared by the compaction
18
+ * writer and the validator's byte-equality bridge, so the two cannot
19
+ * drift. An empty input yields an empty file.
20
+ */
21
+ export declare function encodeCombinedEventLog(perEventBlobs: readonly Uint8Array[]): Uint8Array;