@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
package/src/skill-kind.ts
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
1
|
-
import { type } from "arktype";
|
|
2
|
-
import { getLogger } from "@intx/log";
|
|
3
|
-
import { glob, repoActionToGrantVerb } from "@intx/hub-common";
|
|
4
|
-
import {
|
|
5
|
-
UserPrincipal,
|
|
6
|
-
type AuthorizeFn,
|
|
7
|
-
type KindHandler,
|
|
8
|
-
type Principal,
|
|
9
|
-
type ValidatePushResult,
|
|
10
|
-
} from "./repo-store";
|
|
11
|
-
|
|
12
|
-
const logger = getLogger(["hub-sessions", "skill-kind"]);
|
|
13
|
-
|
|
14
|
-
export type SkillHubPrincipal = { readonly kind: "hub" };
|
|
15
|
-
|
|
16
|
-
export type SkillSidecarPrincipal = {
|
|
17
|
-
readonly kind: "sidecar";
|
|
18
|
-
readonly agentId: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export type SkillPrincipal = SkillHubPrincipal | SkillSidecarPrincipal;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* arktype schema for the SKILL.md frontmatter. Required fields are
|
|
25
|
-
* `name` and `description`; the Claude Code superset of optional fields
|
|
26
|
-
* (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
|
|
27
|
-
* via `onUndeclaredKey("ignore")` but not enforced.
|
|
28
|
-
*
|
|
29
|
-
* The forbidden-name narrow rejects `"anthropic"` and `"claude"`
|
|
30
|
-
* because those values are reserved by the upstream agentskills.io
|
|
31
|
-
* spec for vendor-owned skill packs.
|
|
32
|
-
*/
|
|
33
|
-
export const skillFrontmatterSchema = type({
|
|
34
|
-
name: type(/^[a-z0-9]+(-[a-z0-9]+)*$/)
|
|
35
|
-
.and("string<=64")
|
|
36
|
-
.narrow((n: string, ctx) => {
|
|
37
|
-
if (n === "anthropic" || n === "claude") {
|
|
38
|
-
return ctx.mustBe(`not the reserved name "anthropic" or "claude"`);
|
|
39
|
-
}
|
|
40
|
-
return true;
|
|
41
|
-
}),
|
|
42
|
-
description: type("1 <= string <= 1024").and(type(/^(?!.*<[^>]+>).*$/s)),
|
|
43
|
-
}).onUndeclaredKey("ignore");
|
|
44
|
-
|
|
45
|
-
export type SkillFrontmatter = typeof skillFrontmatterSchema.infer;
|
|
46
|
-
|
|
47
|
-
export type SkillIndexEntry = {
|
|
48
|
-
/** Equal to the containing directory name and to `frontmatter.name`. */
|
|
49
|
-
name: string;
|
|
50
|
-
description: string;
|
|
51
|
-
/** Full parsed frontmatter, including any accepted optional fields. */
|
|
52
|
-
frontmatter: Record<string, unknown>;
|
|
53
|
-
/** Path of the skill subdirectory relative to the asset mount, with trailing slash. */
|
|
54
|
-
workspaceSubpath: string;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type CacheKey = string;
|
|
58
|
-
|
|
59
|
-
function cacheKey(assetId: string, ref: string): CacheKey {
|
|
60
|
-
return `${assetId}\u0000${ref}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const skillIndex = new Map<CacheKey, SkillIndexEntry[]>();
|
|
64
|
-
const pendingIndex = new Map<CacheKey, SkillIndexEntry[]>();
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Returns the parsed skill index for `(assetId, ref)`, or an empty
|
|
68
|
-
* array if no index has been populated yet. The index is refreshed by
|
|
69
|
-
* the kind handler's `onRefUpdated` hook after each successful write.
|
|
70
|
-
*/
|
|
71
|
-
export function getSkillIndex(assetId: string, ref: string): SkillIndexEntry[] {
|
|
72
|
-
return skillIndex.get(cacheKey(assetId, ref)) ?? [];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const FRONTMATTER_DELIMITER = "---";
|
|
76
|
-
|
|
77
|
-
type ParsedSkillMd = {
|
|
78
|
-
frontmatter: Record<string, unknown>;
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
const YamlMapping = type("Record<string, unknown>");
|
|
82
|
-
|
|
83
|
-
function parseSkillMd(body: string): ParsedSkillMd {
|
|
84
|
-
const lines = body.split(/\r?\n/);
|
|
85
|
-
if (lines[0] !== FRONTMATTER_DELIMITER) {
|
|
86
|
-
throw new Error("SKILL.md is missing YAML frontmatter delimiter");
|
|
87
|
-
}
|
|
88
|
-
let endIdx = -1;
|
|
89
|
-
for (let i = 1; i < lines.length; i += 1) {
|
|
90
|
-
if (lines[i] === FRONTMATTER_DELIMITER) {
|
|
91
|
-
endIdx = i;
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
if (endIdx === -1) {
|
|
96
|
-
throw new Error("SKILL.md frontmatter has no closing delimiter");
|
|
97
|
-
}
|
|
98
|
-
const yamlText = lines.slice(1, endIdx).join("\n");
|
|
99
|
-
const parsed: unknown = Bun.YAML.parse(yamlText);
|
|
100
|
-
const validated = YamlMapping(parsed);
|
|
101
|
-
if (validated instanceof type.errors) {
|
|
102
|
-
throw new Error(
|
|
103
|
-
`SKILL.md frontmatter must be a YAML mapping: ${validated.summary}`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
return { frontmatter: validated };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
type ParseOutcome =
|
|
110
|
-
| { ok: true; entry: SkillIndexEntry }
|
|
111
|
-
| { ok: false; reason: string };
|
|
112
|
-
|
|
113
|
-
async function parseSkillEntry(
|
|
114
|
-
subdir: string,
|
|
115
|
-
readBlob: (path: string) => Promise<Uint8Array>,
|
|
116
|
-
): Promise<ParseOutcome> {
|
|
117
|
-
const skillPath = `${subdir}/SKILL.md`;
|
|
118
|
-
let raw: Uint8Array;
|
|
119
|
-
try {
|
|
120
|
-
raw = await readBlob(skillPath);
|
|
121
|
-
} catch (cause) {
|
|
122
|
-
return {
|
|
123
|
-
ok: false,
|
|
124
|
-
reason: `skill ${subdir} is missing SKILL.md: ${
|
|
125
|
-
cause instanceof Error ? cause.message : String(cause)
|
|
126
|
-
}`,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const body = new TextDecoder().decode(raw);
|
|
131
|
-
let parsed: ParsedSkillMd;
|
|
132
|
-
try {
|
|
133
|
-
parsed = parseSkillMd(body);
|
|
134
|
-
} catch (cause) {
|
|
135
|
-
return {
|
|
136
|
-
ok: false,
|
|
137
|
-
reason: `skill ${subdir} frontmatter parse failed: ${
|
|
138
|
-
cause instanceof Error ? cause.message : String(cause)
|
|
139
|
-
}`,
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const result = skillFrontmatterSchema(parsed.frontmatter);
|
|
144
|
-
if (result instanceof type.errors) {
|
|
145
|
-
return {
|
|
146
|
-
ok: false,
|
|
147
|
-
reason: `skill ${subdir} frontmatter is invalid: ${result.summary}`,
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (result.name !== subdir) {
|
|
152
|
-
return {
|
|
153
|
-
ok: false,
|
|
154
|
-
reason: `skill ${subdir} frontmatter.name ${JSON.stringify(
|
|
155
|
-
result.name,
|
|
156
|
-
)} does not match directory name`,
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return {
|
|
161
|
-
ok: true,
|
|
162
|
-
entry: {
|
|
163
|
-
name: result.name,
|
|
164
|
-
description: result.description,
|
|
165
|
-
frontmatter: parsed.frontmatter,
|
|
166
|
-
workspaceSubpath: `${subdir}/`,
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
async function buildSkillIndex(
|
|
172
|
-
topLevelTreePaths: string[],
|
|
173
|
-
readBlob: (path: string) => Promise<Uint8Array>,
|
|
174
|
-
): Promise<
|
|
175
|
-
{ ok: true; entries: SkillIndexEntry[] } | { ok: false; reason: string }
|
|
176
|
-
> {
|
|
177
|
-
const entries: SkillIndexEntry[] = [];
|
|
178
|
-
// Sort so the index ordering is deterministic across reads.
|
|
179
|
-
const subdirs = [...topLevelTreePaths].sort();
|
|
180
|
-
for (const subdir of subdirs) {
|
|
181
|
-
const outcome = await parseSkillEntry(subdir, readBlob);
|
|
182
|
-
if (!outcome.ok) {
|
|
183
|
-
return { ok: false, reason: outcome.reason };
|
|
184
|
-
}
|
|
185
|
-
entries.push(outcome.entry);
|
|
186
|
-
}
|
|
187
|
-
return { ok: true, entries };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export const skillKindHandler: KindHandler = {
|
|
191
|
-
kind: "skill",
|
|
192
|
-
directoryPrefix: "assets/skill",
|
|
193
|
-
async validatePush({
|
|
194
|
-
repoId,
|
|
195
|
-
ref,
|
|
196
|
-
topLevelTreePaths,
|
|
197
|
-
readBlob,
|
|
198
|
-
}): Promise<ValidatePushResult> {
|
|
199
|
-
// Drop any staged entry from a previous attempt first. The
|
|
200
|
-
// substrate calls validatePush before advancing the ref, so a prior
|
|
201
|
-
// validation that was accepted but never followed by onRefUpdated
|
|
202
|
-
// (e.g. the commit step threw after validation succeeded) would
|
|
203
|
-
// otherwise leave a stale entry that a later rejected attempt
|
|
204
|
-
// would silently inherit.
|
|
205
|
-
const key = cacheKey(repoId.id, ref);
|
|
206
|
-
pendingIndex.delete(key);
|
|
207
|
-
|
|
208
|
-
const result = await buildSkillIndex(topLevelTreePaths, readBlob);
|
|
209
|
-
if (!result.ok) {
|
|
210
|
-
logger.debug`skill validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${result.reason}`;
|
|
211
|
-
return { ok: false, reason: result.reason };
|
|
212
|
-
}
|
|
213
|
-
// Stage the parsed index against (repoId.id, ref). The substrate
|
|
214
|
-
// calls onRefUpdated immediately after the ref is advanced and we
|
|
215
|
-
// promote the staged entry into the live cache there.
|
|
216
|
-
pendingIndex.set(key, result.entries);
|
|
217
|
-
return { ok: true };
|
|
218
|
-
},
|
|
219
|
-
onRefUpdated({ repoId, ref }) {
|
|
220
|
-
const key = cacheKey(repoId.id, ref);
|
|
221
|
-
const staged = pendingIndex.get(key);
|
|
222
|
-
if (staged === undefined) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`skillKindHandler.onRefUpdated: no validated tree pending for ${repoId.id} @ ${ref}`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
pendingIndex.delete(key);
|
|
228
|
-
skillIndex.set(key, staged);
|
|
229
|
-
},
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
const SidecarPrincipal = type({
|
|
233
|
-
kind: "'sidecar'",
|
|
234
|
-
agentId: "string",
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
export const skillAuthorize: AuthorizeFn = (
|
|
238
|
-
principal: Principal,
|
|
239
|
-
repoId,
|
|
240
|
-
ref,
|
|
241
|
-
action,
|
|
242
|
-
) => {
|
|
243
|
-
if (repoId.kind !== "skill") {
|
|
244
|
-
return {
|
|
245
|
-
allowed: false,
|
|
246
|
-
reason: `skill authorize received non-skill repo ${repoId.kind}/${repoId.id}`,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
if (principal.kind === "hub") {
|
|
251
|
-
return { allowed: true };
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
if (principal.kind === "sidecar") {
|
|
255
|
-
const parsed = SidecarPrincipal(principal);
|
|
256
|
-
if (parsed instanceof type.errors) {
|
|
257
|
-
return {
|
|
258
|
-
allowed: false,
|
|
259
|
-
reason: `sidecar principal is malformed: ${parsed.summary}`,
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
switch (action) {
|
|
263
|
-
case "createPack":
|
|
264
|
-
case "resolveRef":
|
|
265
|
-
return { allowed: true };
|
|
266
|
-
case "init":
|
|
267
|
-
case "writeTree":
|
|
268
|
-
case "receivePack":
|
|
269
|
-
return {
|
|
270
|
-
allowed: false,
|
|
271
|
-
reason: `sidecars may only read skill assets, not ${action}`,
|
|
272
|
-
};
|
|
273
|
-
default: {
|
|
274
|
-
const _exhaustive: never = action;
|
|
275
|
-
return {
|
|
276
|
-
allowed: false,
|
|
277
|
-
reason: `unhandled action: ${String(_exhaustive)}`,
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (principal.kind === "user") {
|
|
284
|
-
// The route layer has already pre-resolved the grant verdict and
|
|
285
|
-
// attached it as `authz`. The substrate does NOT re-query the
|
|
286
|
-
// grant store here; it (a) checks the bearer-token's claims
|
|
287
|
-
// bound the requested (ref, action) and have not expired, and
|
|
288
|
-
// (b) sanity-checks that the pre-resolved verdict targets this
|
|
289
|
-
// exact resource and grant verb. Both gates must pass before the
|
|
290
|
-
// verdict's `effect` is honoured.
|
|
291
|
-
const parsed = UserPrincipal(principal);
|
|
292
|
-
if (parsed instanceof type.errors) {
|
|
293
|
-
return {
|
|
294
|
-
allowed: false,
|
|
295
|
-
reason: `user principal is malformed: ${parsed.summary}`,
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
if (!parsed.tokenClaims.actions.includes(action)) {
|
|
299
|
-
return {
|
|
300
|
-
allowed: false,
|
|
301
|
-
reason: `token does not grant action ${action}`,
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
// `ref === "*"` is the substrate's sentinel for a bulk read used
|
|
305
|
-
// by `listRefs`. The advertise-refs layer applies the per-ref
|
|
306
|
-
// refPattern filter on the listing it returns, so gating the bulk
|
|
307
|
-
// read on refPattern here would prevent any token with a
|
|
308
|
-
// narrow-pattern (e.g. `refs/heads/main`) from listing refs at
|
|
309
|
-
// all. Only the action and expiry are enforced for the bulk read;
|
|
310
|
-
// every other call site passes a concrete ref and is gated below.
|
|
311
|
-
if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
|
|
312
|
-
return {
|
|
313
|
-
allowed: false,
|
|
314
|
-
reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
if (Date.now() >= parsed.tokenClaims.expiresAt) {
|
|
318
|
-
return {
|
|
319
|
-
allowed: false,
|
|
320
|
-
reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
const expectedResource = `asset:${repoId.id}`;
|
|
324
|
-
if (parsed.authz.resource !== expectedResource) {
|
|
325
|
-
return {
|
|
326
|
-
allowed: false,
|
|
327
|
-
reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
const expectedGrantVerb = repoActionToGrantVerb(action);
|
|
331
|
-
if (parsed.authz.grantVerb !== expectedGrantVerb) {
|
|
332
|
-
return {
|
|
333
|
-
allowed: false,
|
|
334
|
-
reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
if (parsed.authz.effect === "allow") {
|
|
338
|
-
return { allowed: true };
|
|
339
|
-
}
|
|
340
|
-
return {
|
|
341
|
-
allowed: false,
|
|
342
|
-
reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
|
|
343
|
-
};
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
return {
|
|
347
|
-
allowed: false,
|
|
348
|
-
reason: `unknown principal kind: ${principal.kind}`,
|
|
349
|
-
};
|
|
350
|
-
};
|
package/src/ws/index.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
createSidecarRouter,
|
|
3
|
-
type SidecarRouter,
|
|
4
|
-
type SidecarRouterConfig,
|
|
5
|
-
type SidecarConnection,
|
|
6
|
-
type SendPackOptions,
|
|
7
|
-
type WsHandle,
|
|
8
|
-
} from "./sidecar-handler";
|
|
9
|
-
export {
|
|
10
|
-
createSidecarEmitter,
|
|
11
|
-
type SidecarEventEmitter,
|
|
12
|
-
type SidecarEventMap,
|
|
13
|
-
type SidecarEventType,
|
|
14
|
-
type SidecarEventListener,
|
|
15
|
-
type SidecarLookups,
|
|
16
|
-
type SidecarMailPersistedRow,
|
|
17
|
-
type SidecarMailPersistedPayload,
|
|
18
|
-
} from "./sidecar-events";
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from "bun:test";
|
|
2
|
-
import { createSidecarEmitter } from "./sidecar-events";
|
|
3
|
-
|
|
4
|
-
describe("createSidecarEmitter", () => {
|
|
5
|
-
test("delivers events to subscribed listeners", () => {
|
|
6
|
-
const emitter = createSidecarEmitter();
|
|
7
|
-
const seen: { addr: string; sid: string }[] = [];
|
|
8
|
-
emitter.on("agent.event", ({ agentAddress, sessionId }) => {
|
|
9
|
-
seen.push({ addr: agentAddress, sid: sessionId });
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
emitter.emit("agent.event", {
|
|
13
|
-
agentAddress: "a@x",
|
|
14
|
-
sessionId: "s-1",
|
|
15
|
-
event: { type: "x" },
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
expect(seen).toEqual([{ addr: "a@x", sid: "s-1" }]);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("supports multiple listeners on the same event", () => {
|
|
22
|
-
const emitter = createSidecarEmitter();
|
|
23
|
-
const order: string[] = [];
|
|
24
|
-
emitter.on("sidecar.disconnect", () => {
|
|
25
|
-
order.push("a");
|
|
26
|
-
});
|
|
27
|
-
emitter.on("sidecar.disconnect", () => {
|
|
28
|
-
order.push("b");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
emitter.emit("sidecar.disconnect", { agentAddresses: [] });
|
|
32
|
-
|
|
33
|
-
expect(order).toEqual(["a", "b"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("unsubscribe removes the listener", () => {
|
|
37
|
-
const emitter = createSidecarEmitter();
|
|
38
|
-
let count = 0;
|
|
39
|
-
const unsubscribe = emitter.on("sidecar.disconnect", () => {
|
|
40
|
-
count++;
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
emitter.emit("sidecar.disconnect", { agentAddresses: [] });
|
|
44
|
-
unsubscribe();
|
|
45
|
-
emitter.emit("sidecar.disconnect", { agentAddresses: [] });
|
|
46
|
-
|
|
47
|
-
expect(count).toBe(1);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("emit swallows listener errors and continues", () => {
|
|
51
|
-
const emitter = createSidecarEmitter();
|
|
52
|
-
const seen: string[] = [];
|
|
53
|
-
emitter.on("sidecar.disconnect", () => {
|
|
54
|
-
throw new Error("boom");
|
|
55
|
-
});
|
|
56
|
-
emitter.on("sidecar.disconnect", () => {
|
|
57
|
-
seen.push("ran");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
emitter.emit("sidecar.disconnect", { agentAddresses: [] });
|
|
61
|
-
|
|
62
|
-
expect(seen).toEqual(["ran"]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
test("emitAndAwait runs listeners sequentially and rethrows the first failure", async () => {
|
|
66
|
-
const emitter = createSidecarEmitter();
|
|
67
|
-
const seen: string[] = [];
|
|
68
|
-
emitter.on("agent.reconnected", async () => {
|
|
69
|
-
seen.push("a");
|
|
70
|
-
});
|
|
71
|
-
emitter.on("agent.reconnected", async () => {
|
|
72
|
-
seen.push("b");
|
|
73
|
-
throw new Error("listener b failed");
|
|
74
|
-
});
|
|
75
|
-
emitter.on("agent.reconnected", async () => {
|
|
76
|
-
seen.push("c");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
await expect(
|
|
80
|
-
emitter.emitAndAwait("agent.reconnected", { agentAddress: "a@x" }),
|
|
81
|
-
).rejects.toThrow(/listener b failed/);
|
|
82
|
-
|
|
83
|
-
expect(seen).toEqual(["a", "b"]);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("listenerCount reflects current subscriptions", () => {
|
|
87
|
-
const emitter = createSidecarEmitter();
|
|
88
|
-
expect(emitter.listenerCount("agent.reconnected")).toBe(0);
|
|
89
|
-
|
|
90
|
-
const off = emitter.on("agent.reconnected", () => undefined);
|
|
91
|
-
expect(emitter.listenerCount("agent.reconnected")).toBe(1);
|
|
92
|
-
|
|
93
|
-
off();
|
|
94
|
-
expect(emitter.listenerCount("agent.reconnected")).toBe(0);
|
|
95
|
-
});
|
|
96
|
-
});
|
package/src/ws/sidecar-events.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
// Typed event emitter for the sidecar router.
|
|
2
|
-
//
|
|
3
|
-
// The router emits events at the points where wire-layer frame handling
|
|
4
|
-
// completes and a host-side decision or side effect is required. Two
|
|
5
|
-
// emission shapes are exposed:
|
|
6
|
-
//
|
|
7
|
-
// - `emit(type, payload)` — notification semantics. Each listener runs
|
|
8
|
-
// inside its own try/catch; a thrown error is logged and does not
|
|
9
|
-
// affect other listeners or the wire layer. Used for events whose
|
|
10
|
-
// outcome does not feed back into protocol behavior.
|
|
11
|
-
//
|
|
12
|
-
// - `emitAndAwait(type, payload)` — sequential await semantics.
|
|
13
|
-
// Listeners run in registration order; the first rejection propagates
|
|
14
|
-
// to the caller and stops the chain. Used for events whose outcome
|
|
15
|
-
// affects subsequent wire-layer state (e.g. reconnect rollback).
|
|
16
|
-
//
|
|
17
|
-
// The TSDoc on each entry in `SidecarEventMap` records which semantic
|
|
18
|
-
// applies. Mixing the two on a single event is intentional: today's
|
|
19
|
-
// wire layer already has both behaviors, and pretending otherwise
|
|
20
|
-
// would silently change failure handling.
|
|
21
|
-
|
|
22
|
-
import type { PackRejectReason, RepoId } from "@intx/types/sidecar";
|
|
23
|
-
import type { ConnectorThreadState } from "@intx/types/runtime";
|
|
24
|
-
import { getLogger } from "@intx/log";
|
|
25
|
-
|
|
26
|
-
const logger = getLogger(["hub", "ws", "sidecar", "events"]);
|
|
27
|
-
|
|
28
|
-
export type SidecarMailPersistedRow = {
|
|
29
|
-
id: string;
|
|
30
|
-
createdAt: Date;
|
|
31
|
-
direction: "inbound" | "outbound";
|
|
32
|
-
instanceId: string | null;
|
|
33
|
-
address: string;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export type SidecarMailPersistedPayload = SidecarMailPersistedRow & {
|
|
37
|
-
raw: Uint8Array;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type SidecarEventMap = {
|
|
41
|
-
/** Notification. Emitted for every agent.event frame the wire layer
|
|
42
|
-
* decodes. The wire layer also forwards the event to in-process agent
|
|
43
|
-
* subscribers registered via `router.subscribeAgent`; this event is
|
|
44
|
-
* the host-side observation point. */
|
|
45
|
-
"agent.event": {
|
|
46
|
-
agentAddress: string;
|
|
47
|
-
sessionId: string;
|
|
48
|
-
event: unknown;
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/** Notification. Emitted once when a sidecar's connection closes,
|
|
52
|
-
* carrying the set of agent addresses that were registered on that
|
|
53
|
-
* connection. */
|
|
54
|
-
"sidecar.disconnect": {
|
|
55
|
-
agentAddresses: string[];
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/** Notification. Emitted when a mail.outbound frame from a sidecar
|
|
59
|
-
* names recipients that the wire layer could not deliver locally and
|
|
60
|
-
* could not enqueue for a disconnected agent. The host is free to
|
|
61
|
-
* relay it onto an external transport or drop it. */
|
|
62
|
-
"mail.outbound.undelivered": {
|
|
63
|
-
rawMessage: string;
|
|
64
|
-
recipients: string[];
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/** Notification. Emitted once per row produced by the host's
|
|
68
|
-
* `persistMail` lookup. The wire layer calls `persistMail` to obtain
|
|
69
|
-
* the rows; this event fires for each so subscribers can react
|
|
70
|
-
* per-row (e.g. dispatch a delivered event). */
|
|
71
|
-
"mail.persisted": SidecarMailPersistedPayload;
|
|
72
|
-
|
|
73
|
-
/** Awaited. Emitted when an agent.deploy.ack frame arrives. Rejection
|
|
74
|
-
* fails the pending deploy with the listener's error. */
|
|
75
|
-
"agent.deploy.ack": {
|
|
76
|
-
agentAddress: string;
|
|
77
|
-
publicKey: string;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/** Notification. Emitted when the sidecar reports a change to an
|
|
81
|
-
* agent's connector-thread state. The wire layer caches the state
|
|
82
|
-
* per agent so the host can read it via
|
|
83
|
-
* `router.getConnectorState(agentAddress)`; this event is for hosts
|
|
84
|
-
* that want to observe transitions directly. `connectorState` is
|
|
85
|
-
* `null` when the agent has no active connector thread. */
|
|
86
|
-
"connector.state.changed": {
|
|
87
|
-
agentAddress: string;
|
|
88
|
-
connectorState: ConnectorThreadState | null;
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
/** Awaited. Emitted per address after challenge verification
|
|
92
|
-
* succeeds and before the disconnect queue is flushed. Rejection
|
|
93
|
-
* rolls that address back from the routing table; earlier listeners
|
|
94
|
-
* in registration order have already executed and their side effects
|
|
95
|
-
* are not undone. A subsequent reconnect arriving mid-flight may
|
|
96
|
-
* supersede this one, so listeners must be idempotent. */
|
|
97
|
-
"agent.reconnected": {
|
|
98
|
-
agentAddress: string;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
/** Awaited. Emitted per address after the wire layer has confirmed
|
|
102
|
-
* the sidecar's deploy ref is stale relative to the hub's current
|
|
103
|
-
* ref. The listener's job is to push a fresh deploy pack. The wire
|
|
104
|
-
* layer fires this only when staleness is confirmed; subscribing
|
|
105
|
-
* without a `lookupDeployRef` configured on the router will never
|
|
106
|
-
* deliver. */
|
|
107
|
-
"deploy.ref.stale": {
|
|
108
|
-
agentAddress: string;
|
|
109
|
-
};
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
export type SidecarEventType = keyof SidecarEventMap;
|
|
113
|
-
|
|
114
|
-
export type SidecarEventListener<T extends SidecarEventType> = (
|
|
115
|
-
payload: SidecarEventMap[T],
|
|
116
|
-
) => void | Promise<void>;
|
|
117
|
-
|
|
118
|
-
export type SidecarEventEmitter = {
|
|
119
|
-
on<T extends SidecarEventType>(
|
|
120
|
-
type: T,
|
|
121
|
-
listener: SidecarEventListener<T>,
|
|
122
|
-
): () => void;
|
|
123
|
-
emit<T extends SidecarEventType>(type: T, payload: SidecarEventMap[T]): void;
|
|
124
|
-
emitAndAwait<T extends SidecarEventType>(
|
|
125
|
-
type: T,
|
|
126
|
-
payload: SidecarEventMap[T],
|
|
127
|
-
): Promise<void>;
|
|
128
|
-
/** Number of listeners registered for `type`. Wire-layer callers use
|
|
129
|
-
* this to skip an `await` when nothing is listening, preserving the
|
|
130
|
-
* synchronous scheduling of unconfigured-handler paths. */
|
|
131
|
-
listenerCount(type: SidecarEventType): number;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
export function createSidecarEmitter(): SidecarEventEmitter {
|
|
135
|
-
const listeners: { [K in SidecarEventType]: Set<SidecarEventListener<K>> } = {
|
|
136
|
-
"agent.event": new Set(),
|
|
137
|
-
"sidecar.disconnect": new Set(),
|
|
138
|
-
"mail.outbound.undelivered": new Set(),
|
|
139
|
-
"mail.persisted": new Set(),
|
|
140
|
-
"agent.deploy.ack": new Set(),
|
|
141
|
-
"agent.reconnected": new Set(),
|
|
142
|
-
"deploy.ref.stale": new Set(),
|
|
143
|
-
"connector.state.changed": new Set(),
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
function on<T extends SidecarEventType>(
|
|
147
|
-
type: T,
|
|
148
|
-
listener: SidecarEventListener<T>,
|
|
149
|
-
): () => void {
|
|
150
|
-
listeners[type].add(listener);
|
|
151
|
-
return () => {
|
|
152
|
-
listeners[type].delete(listener);
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function emit<T extends SidecarEventType>(
|
|
157
|
-
type: T,
|
|
158
|
-
payload: SidecarEventMap[T],
|
|
159
|
-
): void {
|
|
160
|
-
const set = listeners[type];
|
|
161
|
-
if (set.size === 0) return;
|
|
162
|
-
for (const listener of [...set]) {
|
|
163
|
-
try {
|
|
164
|
-
const result = listener(payload);
|
|
165
|
-
if (result instanceof Promise) {
|
|
166
|
-
result.catch((err: unknown) => {
|
|
167
|
-
logger.warn`Listener for ${type} threw: ${
|
|
168
|
-
err instanceof Error ? err.message : String(err)
|
|
169
|
-
}`;
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
} catch (err) {
|
|
173
|
-
logger.warn`Listener for ${type} threw: ${
|
|
174
|
-
err instanceof Error ? err.message : String(err)
|
|
175
|
-
}`;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
async function emitAndAwait<T extends SidecarEventType>(
|
|
181
|
-
type: T,
|
|
182
|
-
payload: SidecarEventMap[T],
|
|
183
|
-
): Promise<void> {
|
|
184
|
-
const set = listeners[type];
|
|
185
|
-
if (set.size === 0) return;
|
|
186
|
-
for (const listener of [...set]) {
|
|
187
|
-
await listener(payload);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function listenerCount(type: SidecarEventType): number {
|
|
192
|
-
return listeners[type].size;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return { on, emit, emitAndAwait, listenerCount };
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
export type SidecarLookups = {
|
|
199
|
-
/** Returns the hex-encoded Ed25519 public key stored for the address,
|
|
200
|
-
* or `null` if the address is unknown. Used during the reconnect
|
|
201
|
-
* challenge to verify the sidecar's signature. */
|
|
202
|
-
lookupPublicKey?: (agentAddress: string) => Promise<string | null>;
|
|
203
|
-
|
|
204
|
-
/** Returns the hub's current deploy ref for the address, or `null` if
|
|
205
|
-
* no deploy state is tracked. The wire layer compares this against
|
|
206
|
-
* the sidecar's reported ref during reconnect and emits
|
|
207
|
-
* `deploy.ref.stale` only on mismatch. */
|
|
208
|
-
lookupDeployRef?: (agentAddress: string) => Promise<string | null>;
|
|
209
|
-
|
|
210
|
-
/** Persists a delivered outbound mail frame. Returns one row per
|
|
211
|
-
* persisted record; the wire layer attaches `raw` to each row and
|
|
212
|
-
* emits a `mail.persisted` event. */
|
|
213
|
-
persistMail?: (args: {
|
|
214
|
-
senderAddress: string;
|
|
215
|
-
recipients: string[];
|
|
216
|
-
raw: Uint8Array;
|
|
217
|
-
}) => Promise<SidecarMailPersistedRow[]>;
|
|
218
|
-
|
|
219
|
-
/** Ingests a received state pack and returns whether the wire layer
|
|
220
|
-
* should ack or reject the pack to the sidecar. `repoId` identifies the
|
|
221
|
-
* source repo at the hub (e.g. `{ kind: "agent-state", id: agentAddress }`
|
|
222
|
-
* for the agent-state flow). */
|
|
223
|
-
receiveStatePack?: (
|
|
224
|
-
repoId: RepoId,
|
|
225
|
-
pack: Uint8Array,
|
|
226
|
-
ref: string,
|
|
227
|
-
commitSha: string,
|
|
228
|
-
) => Promise<
|
|
229
|
-
{ accepted: true } | { accepted: false; reason: PackRejectReason }
|
|
230
|
-
>;
|
|
231
|
-
};
|