@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.
- package/LICENSE +176 -0
- package/README.md +84 -1
- package/dist/agent-repo.d.ts +89 -0
- package/dist/agent-repo.js +109 -0
- package/dist/agent-state-kind.d.ts +12 -0
- package/dist/agent-state-kind.js +185 -0
- package/dist/asset-service.d.ts +123 -0
- package/dist/asset-service.js +349 -0
- package/dist/available-skills-stanza.d.ts +21 -0
- package/dist/available-skills-stanza.js +32 -0
- package/dist/credential-push.d.ts +32 -0
- package/dist/credential-push.js +85 -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 +357 -0
- package/dist/hub-session-lookups.d.ts +17 -0
- package/dist/hub-session-lookups.js +204 -0
- package/dist/hub-session-orchestrator.d.ts +25 -0
- package/dist/hub-session-orchestrator.js +122 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -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 +1692 -0
- package/dist/repo-store/subscribe-kind.d.ts +53 -0
- package/dist/repo-store/subscribe-kind.js +179 -0
- package/dist/repo-store/types.d.ts +483 -0
- package/dist/repo-store/types.js +42 -0
- package/dist/session-service.d.ts +235 -0
- package/dist/session-service.js +997 -0
- package/dist/skill-kind.d.ts +41 -0
- package/dist/skill-kind.js +288 -0
- package/dist/substrate.d.ts +8 -0
- package/dist/substrate.js +21 -0
- package/dist/workflow-kind.d.ts +21 -0
- package/dist/workflow-kind.js +263 -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 +326 -0
- package/dist/workflow-run-kind.js +2646 -0
- package/dist/workflow-run-reader.d.ts +47 -0
- package/dist/workflow-run-reader.js +157 -0
- package/dist/ws/index.d.ts +3 -0
- package/dist/ws/index.js +3 -0
- package/dist/ws/sidecar-events.d.ts +134 -0
- package/dist/ws/sidecar-events.js +70 -0
- package/dist/ws/sidecar-handler.d.ts +184 -0
- package/dist/ws/sidecar-handler.js +1603 -0
- package/dist/ws/sidecar-token-authenticator.d.ts +15 -0
- package/dist/ws/sidecar-token-authenticator.js +24 -0
- package/package.json +34 -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,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;
|