@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.
- package/README.md +26 -0
- package/package.json +23 -0
- package/src/agent-repo.test.ts +310 -0
- package/src/agent-repo.ts +165 -0
- package/src/agent-state-kind.test.ts +247 -0
- package/src/agent-state-kind.ts +204 -0
- package/src/asset-service.test.ts +540 -0
- package/src/asset-service.ts +378 -0
- package/src/available-skills-stanza.test.ts +87 -0
- package/src/available-skills-stanza.ts +47 -0
- package/src/credential-push.ts +65 -0
- package/src/event-collector-registry.test.ts +73 -0
- package/src/event-collector-registry.ts +171 -0
- package/src/event-collector.test.ts +1387 -0
- package/src/event-collector.ts +424 -0
- package/src/hub-session-lookups.ts +206 -0
- package/src/hub-session-orchestrator.test.ts +510 -0
- package/src/hub-session-orchestrator.ts +213 -0
- package/src/index.ts +78 -0
- package/src/repo-store/index.ts +15 -0
- package/src/repo-store/store.test.ts +1169 -0
- package/src/repo-store/store.ts +428 -0
- package/src/repo-store/types.ts +253 -0
- package/src/session-service.test.ts +895 -0
- package/src/session-service.ts +464 -0
- package/src/skill-kind.test.ts +599 -0
- package/src/skill-kind.ts +350 -0
- package/src/ws/index.ts +18 -0
- package/src/ws/sidecar-events.test.ts +96 -0
- package/src/ws/sidecar-events.ts +231 -0
- package/src/ws/sidecar-handler.test.ts +2217 -0
- package/src/ws/sidecar-handler.ts +1574 -0
- package/tsconfig.json +4 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -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&b</name>");
|
|
82
|
+
expect(out).toContain(
|
|
83
|
+
"<description>Handles A & B (with <stuff> too).</description>",
|
|
84
|
+
);
|
|
85
|
+
expect(out).toContain("<path>workspace/skills/tools/a&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("&", "&")
|
|
45
|
+
.replaceAll("<", "<")
|
|
46
|
+
.replaceAll(">", ">");
|
|
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
|
+
});
|