@intx/hub-sessions 0.1.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.
@@ -0,0 +1,378 @@
1
+ // In-process service for creating and attaching skill-asset repos.
2
+ //
3
+ // Three responsibilities are layered here, mirroring the substrate's
4
+ // own layering (DB row, repo bookkeeping, content validation):
5
+ //
6
+ // createAsset inserts the asset row and initializes an empty
7
+ // skill-kind repo via RepoStore.initRepo.
8
+ // populateAsset drives RepoStore.writeTree, which runs the kind
9
+ // handler's validatePush before advancing the ref.
10
+ // Content rejections surface as AssetValidationError.
11
+ // attachAsset inserts an agent_asset row, surfacing the
12
+ // (agentId, assetId) uniqueness violation (which
13
+ // prevents the same asset being attached to one
14
+ // agent twice) as AssetAttachError.
15
+ //
16
+ // The factory is closure-based to match createAgentRepoStore and
17
+ // createRepoStore. There is no class because there is no per-instance
18
+ // mutable state — every method is a pure function over the deps.
19
+
20
+ import { asc, eq } from "drizzle-orm";
21
+ import { type DB } from "@intx/db";
22
+ import {
23
+ agentAsset as agentAssetTable,
24
+ asset as assetTable,
25
+ } from "@intx/db/schema";
26
+ import { generateId } from "@intx/hub-common";
27
+ import { getLogger } from "@intx/log";
28
+ import { hasCode } from "@intx/types";
29
+ import type { RepoKind } from "@intx/types/sidecar";
30
+
31
+ import type {
32
+ InitRepoOpts,
33
+ Principal,
34
+ RepoStore,
35
+ TreeContent,
36
+ } from "./repo-store";
37
+
38
+ const logger = getLogger(["hub-sessions", "asset-service"]);
39
+
40
+ // Postgres SQLSTATE codes. drizzle / postgres-js surfaces the original
41
+ // error with `code` set; `hasCode` narrows safely.
42
+ const PG_UNIQUE_VIOLATION = "23505";
43
+ const PG_FOREIGN_KEY_VIOLATION = "23503";
44
+
45
+ export type Asset = {
46
+ id: string;
47
+ tenantId: string;
48
+ kind: RepoKind;
49
+ name: string;
50
+ displayName: string | null;
51
+ creatorPrincipalId: string | null;
52
+ createdAt: Date;
53
+ updatedAt: Date;
54
+ };
55
+
56
+ export type AccessMode = "read-only" | "read-write";
57
+
58
+ export type AgentAsset = {
59
+ id: string;
60
+ agentId: string;
61
+ assetId: string;
62
+ ref: string;
63
+ accessMode: AccessMode;
64
+ createdAt: Date;
65
+ };
66
+
67
+ export type AgentAssetWithAsset = AgentAsset & {
68
+ asset: Pick<Asset, "id" | "tenantId" | "kind" | "name" | "displayName">;
69
+ };
70
+
71
+ export type CreateAssetParams = {
72
+ tenantId: string;
73
+ /** Only "skill" is supported. "agent-state" is rejected because those
74
+ * repos are managed by the agent lifecycle, not the asset service. */
75
+ kind: RepoKind;
76
+ name: string;
77
+ displayName?: string;
78
+ creatorPrincipalId?: string;
79
+ /** Forwarded verbatim to `repoStore.initRepo`. Lets the REST route
80
+ * layer ship a per-asset `.gitignore` body (OS/editor cruft + build
81
+ * artefacts + `keys/`) in the genesis tree without the service
82
+ * encoding policy for any one consumer. When omitted, the substrate
83
+ * default body applies. */
84
+ initOpts?: InitRepoOpts;
85
+ };
86
+
87
+ export type PopulateAssetParams = {
88
+ assetId: string;
89
+ ref: string;
90
+ tree: TreeContent;
91
+ /** The principal authorized to write the kind. The substrate's
92
+ * authorize gate uses this; the kind handler also relies on it
93
+ * (e.g. skillAuthorize only permits `kind: "hub"` writes). */
94
+ principal: Principal;
95
+ };
96
+
97
+ export type AttachAssetParams = {
98
+ agentId: string;
99
+ assetId: string;
100
+ ref: string;
101
+ accessMode?: AccessMode;
102
+ };
103
+
104
+ export interface AssetService {
105
+ createAsset(params: CreateAssetParams): Promise<Asset>;
106
+ populateAsset(params: PopulateAssetParams): Promise<{ commitSha: string }>;
107
+ attachAsset(params: AttachAssetParams): Promise<AgentAsset>;
108
+ listAgentAssets(agentId: string): Promise<AgentAssetWithAsset[]>;
109
+ }
110
+
111
+ /** Discriminator for AssetServiceError variants. Lets callers branch
112
+ * without instanceof gymnastics across the different error subclasses. */
113
+ export type AssetServiceErrorReason =
114
+ | "unsupported_kind"
115
+ | "duplicate_asset"
116
+ | "duplicate_attachment"
117
+ | "invalid_name"
118
+ | "invalid_reference"
119
+ | "not_found"
120
+ | "path_violation";
121
+
122
+ // Asset names become the default workspace mountpath segment at
123
+ // session start (`skills/<asset.name>/`). The mountpath segment
124
+ // validator in applyAssetPack rejects anything outside a safe
125
+ // character set; validate at the createAsset boundary so a bad name
126
+ // fails at creation time rather than at materialization time. Names
127
+ // must be lowercase-kebab: lowercase letters, digits, hyphens, with
128
+ // no leading or trailing hyphen.
129
+ const ASSET_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
130
+
131
+ export class AssetServiceError extends Error {
132
+ readonly reason: AssetServiceErrorReason;
133
+
134
+ constructor(
135
+ reason: AssetServiceErrorReason,
136
+ message: string,
137
+ cause?: unknown,
138
+ ) {
139
+ super(message, cause === undefined ? undefined : { cause });
140
+ this.name = "AssetServiceError";
141
+ this.reason = reason;
142
+ }
143
+ }
144
+
145
+ function isAccessMode(value: string): value is AccessMode {
146
+ return value === "read-only" || value === "read-write";
147
+ }
148
+
149
+ function rowToAsset(row: typeof assetTable.$inferSelect): Asset {
150
+ // The schema stores `kind` as plain text. RepoKind is an arktype
151
+ // enum of ("agent-state" | "skill"); narrow by exhaustive check so
152
+ // an out-of-band kind value loudly fails rather than silently
153
+ // mistypes the returned shape.
154
+ let narrowed: RepoKind;
155
+ switch (row.kind) {
156
+ case "agent-state":
157
+ narrowed = "agent-state";
158
+ break;
159
+ case "skill":
160
+ narrowed = "skill";
161
+ break;
162
+ default:
163
+ throw new Error(
164
+ `asset row ${row.id} has unknown kind ${JSON.stringify(row.kind)}`,
165
+ );
166
+ }
167
+ return {
168
+ id: row.id,
169
+ tenantId: row.tenantId,
170
+ kind: narrowed,
171
+ name: row.name,
172
+ displayName: row.displayName,
173
+ creatorPrincipalId: row.creatorPrincipalId,
174
+ createdAt: row.createdAt,
175
+ updatedAt: row.updatedAt,
176
+ };
177
+ }
178
+
179
+ function rowToAgentAsset(row: typeof agentAssetTable.$inferSelect): AgentAsset {
180
+ if (!isAccessMode(row.accessMode)) {
181
+ throw new Error(
182
+ `agent_asset row ${row.id} has unknown accessMode ${JSON.stringify(row.accessMode)}`,
183
+ );
184
+ }
185
+ return {
186
+ id: row.id,
187
+ agentId: row.agentId,
188
+ assetId: row.assetId,
189
+ ref: row.ref,
190
+ accessMode: row.accessMode,
191
+ createdAt: row.createdAt,
192
+ };
193
+ }
194
+
195
+ export function createAssetService(deps: {
196
+ // only the drizzle handle is needed, not the connection pool — symmetric
197
+ // with createHubSessionLookups and its sibling services.
198
+ db: DB["db"];
199
+ repoStore: RepoStore;
200
+ }): AssetService {
201
+ const { db, repoStore } = deps;
202
+
203
+ async function createAsset(params: CreateAssetParams): Promise<Asset> {
204
+ if (params.kind === "agent-state") {
205
+ throw new AssetServiceError(
206
+ "unsupported_kind",
207
+ `createAsset rejects kind "agent-state": agent-state repos are managed by the agent lifecycle, not the asset service`,
208
+ );
209
+ }
210
+
211
+ if (!ASSET_NAME_PATTERN.test(params.name)) {
212
+ throw new AssetServiceError(
213
+ "invalid_name",
214
+ `createAsset rejects name ${JSON.stringify(
215
+ params.name,
216
+ )}: must be lowercase-kebab (letters, digits, hyphens; no leading or trailing hyphen)`,
217
+ );
218
+ }
219
+
220
+ const id = generateId("asset");
221
+ const now = new Date();
222
+ const insertRow = {
223
+ id,
224
+ tenantId: params.tenantId,
225
+ kind: params.kind,
226
+ name: params.name,
227
+ displayName: params.displayName ?? null,
228
+ creatorPrincipalId: params.creatorPrincipalId ?? null,
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ };
232
+
233
+ // Init the repo before the row insert so a repo-init failure leaves
234
+ // no orphan row in the database. initRepo is idempotent and the
235
+ // generated id is locally unique, so a follow-up failure of the row
236
+ // insert (duplicate, FK violation, etc.) leaves at worst an empty
237
+ // unreferenced repo directory — harmless and reused on retry of a
238
+ // logically identical asset. The asset-service db handle does not
239
+ // expose transactions in the current narrowing, so this ordering is
240
+ // the safest cross-cutting fix without widening the dep surface.
241
+ await repoStore.initRepo({ kind: params.kind, id }, params.initOpts);
242
+
243
+ let inserted: typeof assetTable.$inferSelect;
244
+ try {
245
+ const rows = await db.insert(assetTable).values(insertRow).returning();
246
+ const row = rows[0];
247
+ if (row === undefined) {
248
+ throw new Error("insert into asset returned no rows");
249
+ }
250
+ inserted = row;
251
+ } catch (err) {
252
+ if (hasCode(err) && err.code === PG_UNIQUE_VIOLATION) {
253
+ throw new AssetServiceError(
254
+ "duplicate_asset",
255
+ `asset (tenantId=${params.tenantId}, kind=${params.kind}, name=${params.name}) already exists`,
256
+ err,
257
+ );
258
+ }
259
+ throw err;
260
+ }
261
+
262
+ logger.debug`created asset ${id} (kind=${params.kind}, tenant=${params.tenantId}, name=${params.name})`;
263
+
264
+ return rowToAsset(inserted);
265
+ }
266
+
267
+ async function populateAsset(
268
+ params: PopulateAssetParams,
269
+ ): Promise<{ commitSha: string }> {
270
+ // The asset row carries `kind`. We must read it before writing so
271
+ // the RepoId is shaped correctly; without it, callers could write
272
+ // against the wrong kind handler.
273
+ const row = await db.query.asset.findFirst({
274
+ where: eq(assetTable.id, params.assetId),
275
+ });
276
+ if (row === undefined) {
277
+ throw new AssetServiceError(
278
+ "not_found",
279
+ `populateAsset: asset ${params.assetId} not found`,
280
+ );
281
+ }
282
+ const assetRow = rowToAsset(row);
283
+
284
+ try {
285
+ return await repoStore.writeTree(
286
+ params.principal,
287
+ { kind: assetRow.kind, id: assetRow.id },
288
+ params.ref,
289
+ params.tree,
290
+ );
291
+ } catch (err) {
292
+ const msg = err instanceof Error ? err.message : String(err);
293
+ if (msg.startsWith("path_violation:")) {
294
+ throw new AssetServiceError("path_violation", msg, err);
295
+ }
296
+ throw err;
297
+ }
298
+ }
299
+
300
+ async function attachAsset(params: AttachAssetParams): Promise<AgentAsset> {
301
+ const id = generateId("agentAsset");
302
+ const accessMode = params.accessMode ?? "read-only";
303
+ const insertRow = {
304
+ id,
305
+ agentId: params.agentId,
306
+ assetId: params.assetId,
307
+ ref: params.ref,
308
+ accessMode,
309
+ createdAt: new Date(),
310
+ };
311
+
312
+ let inserted: typeof agentAssetTable.$inferSelect;
313
+ try {
314
+ const rows = await db
315
+ .insert(agentAssetTable)
316
+ .values(insertRow)
317
+ .returning();
318
+ const row = rows[0];
319
+ if (row === undefined) {
320
+ throw new Error("insert into agent_asset returned no rows");
321
+ }
322
+ inserted = row;
323
+ } catch (err) {
324
+ if (hasCode(err) && err.code === PG_UNIQUE_VIOLATION) {
325
+ throw new AssetServiceError(
326
+ "duplicate_attachment",
327
+ `agent_asset (agentId=${params.agentId}, assetId=${params.assetId}) already attached`,
328
+ err,
329
+ );
330
+ }
331
+ if (hasCode(err) && err.code === PG_FOREIGN_KEY_VIOLATION) {
332
+ throw new AssetServiceError(
333
+ "invalid_reference",
334
+ `agent_asset (agentId=${params.agentId}, assetId=${params.assetId}) references a missing agent or asset`,
335
+ err,
336
+ );
337
+ }
338
+ throw err;
339
+ }
340
+
341
+ return rowToAgentAsset(inserted);
342
+ }
343
+
344
+ async function listAgentAssets(
345
+ agentId: string,
346
+ ): Promise<AgentAssetWithAsset[]> {
347
+ // Order by (createdAt, id) so the row sequence is stable across reads
348
+ // — the available_skills stanza and pack fan-out both depend on a
349
+ // deterministic order, and Postgres does not guarantee one without
350
+ // an explicit orderBy.
351
+ const rows = await db
352
+ .select({
353
+ agentAsset: agentAssetTable,
354
+ asset: assetTable,
355
+ })
356
+ .from(agentAssetTable)
357
+ .innerJoin(assetTable, eq(agentAssetTable.assetId, assetTable.id))
358
+ .where(eq(agentAssetTable.agentId, agentId))
359
+ .orderBy(asc(agentAssetTable.createdAt), asc(agentAssetTable.id));
360
+
361
+ return rows.map((row) => {
362
+ const aa = rowToAgentAsset(row.agentAsset);
363
+ const a = rowToAsset(row.asset);
364
+ return {
365
+ ...aa,
366
+ asset: {
367
+ id: a.id,
368
+ tenantId: a.tenantId,
369
+ kind: a.kind,
370
+ name: a.name,
371
+ displayName: a.displayName,
372
+ },
373
+ };
374
+ });
375
+ }
376
+
377
+ return { createAsset, populateAsset, attachAsset, listAgentAssets };
378
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ buildAvailableSkillsStanza,
4
+ type AvailableSkillEntry,
5
+ } from "./available-skills-stanza";
6
+
7
+ describe("buildAvailableSkillsStanza", () => {
8
+ test("returns empty string when no entries are provided", () => {
9
+ expect(buildAvailableSkillsStanza([])).toBe("");
10
+ });
11
+
12
+ test("renders a single skill in the expected shape", () => {
13
+ const entries: AvailableSkillEntry[] = [
14
+ {
15
+ qualifiedName: "greeter/wave",
16
+ description: "Waves at the user.",
17
+ workspacePath: "workspace/skills/greeter/wave/",
18
+ },
19
+ ];
20
+ expect(buildAvailableSkillsStanza(entries)).toBe(
21
+ [
22
+ "<available_skills>",
23
+ " <skill>",
24
+ " <name>greeter/wave</name>",
25
+ " <description>Waves at the user.</description>",
26
+ " <path>workspace/skills/greeter/wave/</path>",
27
+ " </skill>",
28
+ "</available_skills>",
29
+ ].join("\n"),
30
+ );
31
+ });
32
+
33
+ test("preserves declared order across multiple skills in one asset", () => {
34
+ const entries: AvailableSkillEntry[] = [
35
+ {
36
+ qualifiedName: "tools/alpha",
37
+ description: "First.",
38
+ workspacePath: "workspace/skills/tools/alpha/",
39
+ },
40
+ {
41
+ qualifiedName: "tools/beta",
42
+ description: "Second.",
43
+ workspacePath: "workspace/skills/tools/beta/",
44
+ },
45
+ ];
46
+ const out = buildAvailableSkillsStanza(entries);
47
+ const alphaIdx = out.indexOf("tools/alpha");
48
+ const betaIdx = out.indexOf("tools/beta");
49
+ expect(alphaIdx).toBeGreaterThan(-1);
50
+ expect(betaIdx).toBeGreaterThan(-1);
51
+ expect(alphaIdx).toBeLessThan(betaIdx);
52
+ });
53
+
54
+ test("qualifies same-named skills across distinct assets", () => {
55
+ const entries: AvailableSkillEntry[] = [
56
+ {
57
+ qualifiedName: "ops/deploy",
58
+ description: "Ops deploy.",
59
+ workspacePath: "workspace/skills/ops/deploy/",
60
+ },
61
+ {
62
+ qualifiedName: "web/deploy",
63
+ description: "Web deploy.",
64
+ workspacePath: "workspace/skills/web/deploy/",
65
+ },
66
+ ];
67
+ const out = buildAvailableSkillsStanza(entries);
68
+ expect(out).toContain("<name>ops/deploy</name>");
69
+ expect(out).toContain("<name>web/deploy</name>");
70
+ });
71
+
72
+ test("XML-escapes ampersands and angle brackets in field values", () => {
73
+ const entries: AvailableSkillEntry[] = [
74
+ {
75
+ qualifiedName: "tools/a&b",
76
+ description: "Handles A & B (with <stuff> too).",
77
+ workspacePath: "workspace/skills/tools/a&b/",
78
+ },
79
+ ];
80
+ const out = buildAvailableSkillsStanza(entries);
81
+ expect(out).toContain("<name>tools/a&amp;b</name>");
82
+ expect(out).toContain(
83
+ "<description>Handles A &amp; B (with &lt;stuff&gt; too).</description>",
84
+ );
85
+ expect(out).toContain("<path>workspace/skills/tools/a&amp;b/</path>");
86
+ });
87
+ });
@@ -0,0 +1,47 @@
1
+ export type AvailableSkillEntry = {
2
+ /** Qualified skill identifier in the form `<asset.name>/<skill-name>`. */
3
+ qualifiedName: string;
4
+ /** SKILL.md frontmatter description, verbatim. */
5
+ description: string;
6
+ /** Workspace-relative path the agent's `read_file` should target,
7
+ * shaped like `workspace/<mountPath>/<skill-name>/`. */
8
+ workspacePath: string;
9
+ };
10
+
11
+ /**
12
+ * Render the `<available_skills>` stanza appended to the agent's
13
+ * system prompt. Returns the empty string when `entries` is empty —
14
+ * an empty `<available_skills></available_skills>` wrapper would be
15
+ * misleading noise for agents with no skills attached.
16
+ *
17
+ * Values are XML-escaped at the boundary. The skill kind handler
18
+ * already rejects descriptions containing literal `<` or `>` so the
19
+ * `&` escape is the practical case in production; the others are
20
+ * defensive.
21
+ */
22
+ export function buildAvailableSkillsStanza(
23
+ entries: AvailableSkillEntry[],
24
+ ): string {
25
+ if (entries.length === 0) {
26
+ return "";
27
+ }
28
+ const lines = ["<available_skills>"];
29
+ for (const entry of entries) {
30
+ lines.push(" <skill>");
31
+ lines.push(` <name>${escapeXml(entry.qualifiedName)}</name>`);
32
+ lines.push(
33
+ ` <description>${escapeXml(entry.description)}</description>`,
34
+ );
35
+ lines.push(` <path>${escapeXml(entry.workspacePath)}</path>`);
36
+ lines.push(" </skill>");
37
+ }
38
+ lines.push("</available_skills>");
39
+ return lines.join("\n");
40
+ }
41
+
42
+ function escapeXml(value: string): string {
43
+ return value
44
+ .replaceAll("&", "&amp;")
45
+ .replaceAll("<", "&lt;")
46
+ .replaceAll(">", "&gt;");
47
+ }
@@ -0,0 +1,65 @@
1
+ // Shared logic for pushing inference-source updates to sidecars.
2
+ //
3
+ // Used by the credentials PATCH route to broadcast updates to every running
4
+ // instance in the tenant after a credential secret is rotated.
5
+
6
+ import { eq, and } from "drizzle-orm";
7
+ import { getLogger } from "@intx/log";
8
+ import { agentInstance } from "@intx/db/schema";
9
+ import { resolveInstanceSources } from "@intx/db";
10
+ import type { DB } from "@intx/db";
11
+
12
+ import type { SidecarRouter } from "./ws/sidecar-handler";
13
+
14
+ const log = getLogger(["hub", "credentials"]);
15
+
16
+ /**
17
+ * After a credential secret is rotated, find all running instances in the
18
+ * tenant that may use credentials from the affected provider, re-resolve
19
+ * their full sources array, and push updates to sidecars.
20
+ *
21
+ * Errors are logged per-instance but do not propagate.
22
+ *
23
+ * **Silent no-op when sources is empty.** `resolveInstanceSources`
24
+ * returns `[]` when an instance's agent has malformed
25
+ * `credentialRequirements` or `modelConfig`. This function skips those
26
+ * instances without emitting a log line of its own — the resolver's
27
+ * `db.credentials` logger is the only signal. When a credential rotation
28
+ * fails to reach an agent operators expect it to reach, grep the
29
+ * `db.credentials` logger for `Invalid modelConfig` or
30
+ * `Invalid credential requirements` warnings keyed on the agent id.
31
+ */
32
+ export async function pushSourceUpdates(
33
+ db: DB["db"],
34
+ sidecarRouter: SidecarRouter,
35
+ tenantId: string,
36
+ ): Promise<void> {
37
+ const instances = await db.query.agentInstance.findMany({
38
+ where: and(
39
+ eq(agentInstance.tenantId, tenantId),
40
+ eq(agentInstance.status, "running"),
41
+ ),
42
+ });
43
+
44
+ if (instances.length === 0) return;
45
+
46
+ const results = await Promise.allSettled(
47
+ instances.map(async (instance) => {
48
+ const sources = await resolveInstanceSources(db, tenantId, instance);
49
+ if (sources.length === 0) return;
50
+ const [first] = sources;
51
+ if (first === undefined) return;
52
+ await sidecarRouter.sendSourcesUpdate(
53
+ instance.address,
54
+ sources,
55
+ first.id,
56
+ );
57
+ }),
58
+ );
59
+
60
+ for (const result of results) {
61
+ if (result.status === "rejected") {
62
+ log.warn`Failed to push source update: ${String(result.reason)}`;
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import type { InferenceEvent } from "@intx/types/runtime";
3
+ import { deriveStatus } from "./event-collector-registry";
4
+
5
+ function event(
6
+ type: string,
7
+ data: Record<string, unknown> = {},
8
+ ): InferenceEvent {
9
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test helper: type string is the correct discriminant
10
+ return { type, seq: 1, data } as InferenceEvent;
11
+ }
12
+
13
+ describe("deriveStatus", () => {
14
+ test("reactor.start does not set busy", () => {
15
+ expect(deriveStatus(event("reactor.start"))).toBeNull();
16
+ });
17
+
18
+ test("inference.start sets busy", () => {
19
+ expect(deriveStatus(event("inference.start", { model: "test" }))).toEqual({
20
+ status: "busy",
21
+ });
22
+ });
23
+
24
+ test("connector.reply sets idle", () => {
25
+ expect(deriveStatus(event("connector.reply", { content: "hi" }))).toEqual({
26
+ status: "idle",
27
+ });
28
+ });
29
+
30
+ test("reactor.done sets idle", () => {
31
+ expect(deriveStatus(event("reactor.done"))).toEqual({ status: "idle" });
32
+ });
33
+
34
+ test("reactor.gate.cleared sets busy", () => {
35
+ expect(
36
+ deriveStatus(event("reactor.gate.cleared", { gateId: "g1" })),
37
+ ).toEqual({ status: "busy" });
38
+ });
39
+
40
+ test("reactor.gate.blocked with approval sets waiting_approval", () => {
41
+ expect(
42
+ deriveStatus(
43
+ event("reactor.gate.blocked", { reason: "approval", gateId: "g1" }),
44
+ ),
45
+ ).toEqual({ status: "waiting_approval" });
46
+ });
47
+
48
+ test("reactor.gate.blocked with non-approval reason returns null", () => {
49
+ expect(
50
+ deriveStatus(
51
+ event("reactor.gate.blocked", { reason: "payment", gateId: "g1" }),
52
+ ),
53
+ ).toBeNull();
54
+ });
55
+
56
+ test("fatal reactor.error sets idle", () => {
57
+ expect(
58
+ deriveStatus(event("reactor.error", { error: "boom", fatal: true })),
59
+ ).toEqual({ status: "idle" });
60
+ });
61
+
62
+ test("non-fatal reactor.error returns null", () => {
63
+ expect(
64
+ deriveStatus(event("reactor.error", { error: "oops", fatal: false })),
65
+ ).toBeNull();
66
+ });
67
+
68
+ test("unrecognized event returns null", () => {
69
+ expect(
70
+ deriveStatus(event("inference.text.delta", { token: "hi" })),
71
+ ).toBeNull();
72
+ });
73
+ });