@intentius/chant-lexicon-fly 0.18.6 → 0.18.8
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/op/activities/emulator-images.d.ts +1 -1
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/sprite-config.d.ts +91 -0
- package/dist/op/activities/sprite-config.d.ts.map +1 -0
- package/dist/op/activities/sprite-fs.d.ts +96 -0
- package/dist/op/activities/sprite-fs.d.ts.map +1 -0
- package/dist/op/activities/sprite-tasks.d.ts +54 -0
- package/dist/op/activities/sprite-tasks.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +10 -6
- package/dist/op/activities/sprites-contract.d.ts.map +1 -1
- package/dist/op/activities/sprites-fake.d.ts +37 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +9 -0
- package/src/op/activities/emulator-images.ts +1 -1
- package/src/op/activities/index.ts +56 -0
- package/src/op/activities/sprite-config.docker.integration.test.ts +98 -0
- package/src/op/activities/sprite-config.test.ts +159 -0
- package/src/op/activities/sprite-config.ts +246 -0
- package/src/op/activities/sprite-fs.test.ts +136 -0
- package/src/op/activities/sprite-fs.ts +217 -0
- package/src/op/activities/sprite-tasks.test.ts +165 -0
- package/src/op/activities/sprite-tasks.ts +91 -0
- package/src/op/activities/sprites-contract.test.ts +24 -6
- package/src/op/activities/sprites-contract.ts +27 -6
- package/src/op/activities/sprites-fake.ts +201 -2
- package/src/skills/chant-fly-sprites.md +14 -0
|
@@ -33,6 +33,17 @@ interface StoredCheckpoint {
|
|
|
33
33
|
fs: Record<string, string>;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
interface StoredService {
|
|
37
|
+
name: string;
|
|
38
|
+
cmd: string;
|
|
39
|
+
args?: string[];
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
dir?: string;
|
|
42
|
+
needs?: string[];
|
|
43
|
+
http_port?: number;
|
|
44
|
+
state: { name: string; pid: number; status: string; started_at?: string };
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
interface SpriteState {
|
|
37
48
|
id: string;
|
|
38
49
|
status: SpriteStatus;
|
|
@@ -44,6 +55,12 @@ interface SpriteState {
|
|
|
44
55
|
/** Monotonic version counter for `v<N>` ids. */
|
|
45
56
|
version: number;
|
|
46
57
|
policy?: unknown;
|
|
58
|
+
/** Outbound network policy (whole-object replace via /policy/network). */
|
|
59
|
+
netPolicy: Array<{ domain: string; action: string }>;
|
|
60
|
+
/** Background services keyed by name (create-or-update via PUT). */
|
|
61
|
+
services: Record<string, StoredService>;
|
|
62
|
+
/** Keep-alive tasks keyed by name; while any exists the sprite stays active. */
|
|
63
|
+
tasks: Record<string, { name: string; expire?: number | string }>;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
interface ExecResult {
|
|
@@ -68,7 +85,12 @@ export function fakeExec(sprite: SpriteState, cmd: string): ExecResult {
|
|
|
68
85
|
if (!seg) continue;
|
|
69
86
|
|
|
70
87
|
let m: RegExpMatchArray | null;
|
|
71
|
-
if ((m = seg.match(/^
|
|
88
|
+
if ((m = seg.match(/^cat\s+(\S+)\s*>\s*(\S+)$/))) {
|
|
89
|
+
// Copy a file: `cat SRC > DEST`. Lets an Op stage input with spriteWriteFile,
|
|
90
|
+
// process it with exec, then read the result with spriteReadFile.
|
|
91
|
+
sprite.fs[m[2]] = sprite.fs[m[1]] ?? "";
|
|
92
|
+
exitCode = 0;
|
|
93
|
+
} else if ((m = seg.match(/^echo\s+(.+?)\s*>\s*(\S+)$/))) {
|
|
72
94
|
sprite.fs[m[2]] = unquote(m[1]);
|
|
73
95
|
exitCode = 0;
|
|
74
96
|
} else if ((m = seg.match(/^echo\s+(.+)$/))) {
|
|
@@ -135,6 +157,36 @@ async function readBody(req: IncomingMessage): Promise<unknown> {
|
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
159
|
|
|
160
|
+
/** Read the request body as raw text (for the filesystem write endpoint). */
|
|
161
|
+
async function readRawBody(req: IncomingMessage): Promise<string> {
|
|
162
|
+
const chunks: Buffer[] = [];
|
|
163
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
164
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Immediate children of `dir` in a flat `path → contents` map: a key
|
|
169
|
+
* `${dir}/name` is a file; `${dir}/name/...` contributes the dir `name` once.
|
|
170
|
+
* Pure — the filesystem-list model for the fake.
|
|
171
|
+
*/
|
|
172
|
+
export function fakeListDir(fs: Record<string, string>, dir: string): Array<{ name: string; type: "file" | "dir"; size?: number }> {
|
|
173
|
+
const prefix = dir.replace(/\/+$/, "") + "/";
|
|
174
|
+
const files = new Map<string, number>();
|
|
175
|
+
const dirs = new Set<string>();
|
|
176
|
+
for (const [key, val] of Object.entries(fs)) {
|
|
177
|
+
if (!key.startsWith(prefix)) continue;
|
|
178
|
+
const rest = key.slice(prefix.length);
|
|
179
|
+
if (!rest) continue;
|
|
180
|
+
const slash = rest.indexOf("/");
|
|
181
|
+
if (slash === -1) files.set(rest, val.length);
|
|
182
|
+
else dirs.add(rest.slice(0, slash));
|
|
183
|
+
}
|
|
184
|
+
return [
|
|
185
|
+
...[...dirs].sort().map((name) => ({ name, type: "dir" as const })),
|
|
186
|
+
...[...files.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, size]) => ({ name, type: "file" as const, size })),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
|
|
138
190
|
/**
|
|
139
191
|
* Start the in-process Sprites fake on an ephemeral port. Returns its base
|
|
140
192
|
* `url` (feed it to `SPRITES_BASE_URL`) and a `close()`.
|
|
@@ -211,11 +263,155 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
211
263
|
checkpoints: [],
|
|
212
264
|
version: 0,
|
|
213
265
|
policy: body.policy,
|
|
266
|
+
netPolicy: [],
|
|
267
|
+
services: {},
|
|
268
|
+
tasks: {},
|
|
214
269
|
};
|
|
215
270
|
sprites.set(id, sprite);
|
|
216
271
|
return send(201, { id: sprite.id, url: sprite.url });
|
|
217
272
|
}
|
|
218
273
|
|
|
274
|
+
// Keep-alive tasks: /v1/sprites/{id}/tasks[/{name}].
|
|
275
|
+
const tm = path.match(/^\/v1\/sprites\/([^/]+)\/tasks(?:\/([^/]+))?\/?$/);
|
|
276
|
+
if (tm) {
|
|
277
|
+
const id = decodeURIComponent(tm[1]);
|
|
278
|
+
const name = tm[2] ? decodeURIComponent(tm[2]) : undefined;
|
|
279
|
+
const sprite = sprites.get(id);
|
|
280
|
+
if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
|
|
281
|
+
|
|
282
|
+
if (method === "GET" && !name) return send(200, Object.values(sprite.tasks));
|
|
283
|
+
if (method === "POST" && !name) {
|
|
284
|
+
const b = ((await readBody(req)) ?? {}) as { name?: string; expire?: number | string };
|
|
285
|
+
if (!b.name) return send(400, { error: "name is required" });
|
|
286
|
+
sprite.tasks[b.name] = { name: b.name, expire: b.expire };
|
|
287
|
+
return send(201, { name: b.name, expires_at: "2026-01-01T00:00:00Z" });
|
|
288
|
+
}
|
|
289
|
+
if (method === "PUT" && name) {
|
|
290
|
+
const b = ((await readBody(req)) ?? {}) as { expire?: number | string };
|
|
291
|
+
const t = sprite.tasks[name];
|
|
292
|
+
if (!t) return send(404, { error: `no task ${name}` });
|
|
293
|
+
t.expire = b.expire ?? t.expire;
|
|
294
|
+
return send(200, { name, expires_at: "2026-01-01T00:00:00Z" });
|
|
295
|
+
}
|
|
296
|
+
if (method === "DELETE" && name) {
|
|
297
|
+
if (!(name in sprite.tasks)) return send(404, { error: `no task ${name}` });
|
|
298
|
+
delete sprite.tasks[name];
|
|
299
|
+
return send(204, {});
|
|
300
|
+
}
|
|
301
|
+
return send(404, { error: `not found: ${method} ${path}` });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Network policy: GET/POST /v1/sprites/{id}/policy/network (whole-object replace).
|
|
305
|
+
const pm = path.match(/^\/v1\/sprites\/([^/]+)\/policy\/network\/?$/);
|
|
306
|
+
if (pm) {
|
|
307
|
+
const id = decodeURIComponent(pm[1]);
|
|
308
|
+
const sprite = sprites.get(id);
|
|
309
|
+
if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
|
|
310
|
+
if (method === "GET") return send(200, { rules: sprite.netPolicy });
|
|
311
|
+
if (method === "POST") {
|
|
312
|
+
const body = ((await readBody(req)) ?? {}) as { rules?: Array<{ domain: string; action: string }> };
|
|
313
|
+
sprite.netPolicy = body.rules ?? [];
|
|
314
|
+
return send(200, { rules: sprite.netPolicy });
|
|
315
|
+
}
|
|
316
|
+
return send(404, { error: `not found: ${method} ${path}` });
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Services: /v1/sprites/{id}/services[/{svc}[/start|stop|restart]].
|
|
320
|
+
const svcm = path.match(/^\/v1\/sprites\/([^/]+)\/services(?:\/([^/]+)(\/start|\/stop|\/restart)?)?\/?$/);
|
|
321
|
+
if (svcm) {
|
|
322
|
+
const id = decodeURIComponent(svcm[1]);
|
|
323
|
+
const svc = svcm[2] ? decodeURIComponent(svcm[2]) : undefined;
|
|
324
|
+
const action = svcm[3];
|
|
325
|
+
const sprite = sprites.get(id);
|
|
326
|
+
if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
|
|
327
|
+
|
|
328
|
+
// GET /services — list.
|
|
329
|
+
if (method === "GET" && !svc) return send(200, Object.values(sprite.services));
|
|
330
|
+
|
|
331
|
+
if (svc && !action) {
|
|
332
|
+
// GET /services/{svc}
|
|
333
|
+
if (method === "GET") {
|
|
334
|
+
const s = sprite.services[svc];
|
|
335
|
+
return s ? send(200, s) : send(404, { error: `no service ${svc}` });
|
|
336
|
+
}
|
|
337
|
+
// PUT /services/{svc} — create or update.
|
|
338
|
+
if (method === "PUT") {
|
|
339
|
+
const b = ((await readBody(req)) ?? {}) as Omit<StoredService, "name" | "state">;
|
|
340
|
+
sprite.services[svc] = {
|
|
341
|
+
name: svc,
|
|
342
|
+
cmd: b.cmd,
|
|
343
|
+
args: b.args,
|
|
344
|
+
env: b.env,
|
|
345
|
+
dir: b.dir,
|
|
346
|
+
needs: b.needs,
|
|
347
|
+
http_port: b.http_port,
|
|
348
|
+
state: sprite.services[svc]?.state ?? { name: svc, pid: 0, status: "stopped" },
|
|
349
|
+
};
|
|
350
|
+
return send(200, sprite.services[svc]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// POST /services/{svc}/start|stop|restart — NDJSON, flips status.
|
|
355
|
+
if (method === "POST" && svc && action) {
|
|
356
|
+
const s = sprite.services[svc];
|
|
357
|
+
if (!s) return send(404, { error: `no service ${svc}` });
|
|
358
|
+
const stopped = action === "/stop";
|
|
359
|
+
s.state = { name: svc, pid: stopped ? 0 : 4321, status: stopped ? "stopped" : "running", started_at: new Date().toISOString() };
|
|
360
|
+
return sendNdjson(200, [
|
|
361
|
+
{ type: stopped ? "stopping" : "started", data: `${svc} ${stopped ? "stopping" : "started"}` },
|
|
362
|
+
{ type: "complete", data: `${svc} ${action.slice(1)} complete` },
|
|
363
|
+
]);
|
|
364
|
+
}
|
|
365
|
+
return send(404, { error: `not found: ${method} ${path}` });
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Filesystem API: /v1/sprites/{id}/fs/{read|write|list|delete}. read/write
|
|
369
|
+
// move raw bytes; list/delete use query params + JSON/empty responses.
|
|
370
|
+
const fsm = path.match(/^\/v1\/sprites\/([^/]+)\/fs\/(read|write|list|delete)\/?$/);
|
|
371
|
+
if (fsm) {
|
|
372
|
+
const id = decodeURIComponent(fsm[1]);
|
|
373
|
+
const op = fsm[2];
|
|
374
|
+
const sprite = sprites.get(id);
|
|
375
|
+
if (!sprite || sprite.status === "destroyed") return send(404, { error: `no sprite ${id}` });
|
|
376
|
+
const p = url.searchParams.get("path") ?? "";
|
|
377
|
+
if (!p) return send(400, { error: "path is required" });
|
|
378
|
+
const sendRaw = (status: number, body: string): void => {
|
|
379
|
+
res.writeHead(status, { "content-type": "application/octet-stream" });
|
|
380
|
+
res.end(body);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
if (method === "PUT" && op === "write") {
|
|
384
|
+
sprite.fs[p] = await readRawBody(req);
|
|
385
|
+
return send(200, {});
|
|
386
|
+
}
|
|
387
|
+
if (method === "GET" && op === "read") {
|
|
388
|
+
const content = sprite.fs[p];
|
|
389
|
+
if (content === undefined) return send(404, { error: `no file ${p}` });
|
|
390
|
+
return sendRaw(200, content);
|
|
391
|
+
}
|
|
392
|
+
if (method === "GET" && op === "list") {
|
|
393
|
+
return send(200, fakeListDir(sprite.fs, p));
|
|
394
|
+
}
|
|
395
|
+
if (method === "DELETE" && op === "delete") {
|
|
396
|
+
const recursive = url.searchParams.get("recursive") === "true";
|
|
397
|
+
if (recursive) {
|
|
398
|
+
const prefix = p.replace(/\/+$/, "");
|
|
399
|
+
let removed = 0;
|
|
400
|
+
for (const key of Object.keys(sprite.fs)) {
|
|
401
|
+
if (key === prefix || key.startsWith(prefix + "/")) {
|
|
402
|
+
delete sprite.fs[key];
|
|
403
|
+
removed += 1;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
return removed > 0 ? send(200, {}) : send(404, { error: `no path ${p}` });
|
|
407
|
+
}
|
|
408
|
+
if (!(p in sprite.fs)) return send(404, { error: `no file ${p}` });
|
|
409
|
+
delete sprite.fs[p];
|
|
410
|
+
return send(200, {});
|
|
411
|
+
}
|
|
412
|
+
return send(404, { error: `not found: ${method} ${path}` });
|
|
413
|
+
}
|
|
414
|
+
|
|
219
415
|
const m = path.match(/^\/v1\/sprites\/([^/]+)(\/checkpoint|\/checkpoints(?:\/([^/]+)(\/restore)?)?)?\/?$/);
|
|
220
416
|
if (m) {
|
|
221
417
|
const id = decodeURIComponent(m[1]);
|
|
@@ -282,7 +478,7 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
282
478
|
return send(200, {});
|
|
283
479
|
}
|
|
284
480
|
|
|
285
|
-
// GET /v1/sprites/{id} — inspection (fs + checkpoint ids), used by tests/verify.
|
|
481
|
+
// GET /v1/sprites/{id} — inspection (fs + checkpoint ids + config), used by tests/verify.
|
|
286
482
|
if (method === "GET" && !sub) {
|
|
287
483
|
return send(200, {
|
|
288
484
|
id: sprite.id,
|
|
@@ -290,6 +486,9 @@ export function createSpritesFake(): Promise<{ url: string; close(): Promise<voi
|
|
|
290
486
|
url: sprite.url,
|
|
291
487
|
fs: sprite.fs,
|
|
292
488
|
checkpoints: sprite.checkpoints.map((c) => c.id),
|
|
489
|
+
netPolicy: sprite.netPolicy,
|
|
490
|
+
services: sprite.services,
|
|
491
|
+
tasks: sprite.tasks,
|
|
293
492
|
});
|
|
294
493
|
}
|
|
295
494
|
}
|
|
@@ -99,6 +99,20 @@ The offline, Docker-free emulator that CI runs against is `createSpritesFake()`
|
|
|
99
99
|
|
|
100
100
|
The real Sprites REST surface is provisional (S6, tracked in #766): the endpoint constants may still move to match the official API. The activity input and output contracts (the `Args` and `Result` shapes shown above) are the stable interface the Ops and the emulator are written against, so build your Ops on those.
|
|
101
101
|
|
|
102
|
+
## Beyond the five: filesystem, config, and keep-alive
|
|
103
|
+
|
|
104
|
+
The same lexicon ships more Sprite primitives, all imported from `@intentius/chant-lexicon-fly` and resolved by `loadActivities(["fly"])`:
|
|
105
|
+
|
|
106
|
+
| Family | Activities | Use |
|
|
107
|
+
|--------|-----------|-----|
|
|
108
|
+
| Filesystem (#848) | `spriteWriteFile` / `spriteReadFile` / `spriteListDir` / `spriteRemove` | stage an input file and read a result out without shelling `spriteExec` + `cat` |
|
|
109
|
+
| Config reconcile (#849) | `spriteApplyNetworkPolicy` / `spriteApplyServices` | reconcile a Sprite's egress allowlist and background services against typed config (validated before any HTTP; a whole-object replace for policy, create-or-update by name for services) |
|
|
110
|
+
| Keep-alive (#847) | `spriteTaskCreate` / `spriteTaskRefresh` / `spriteTaskRelease` | hold a Sprite active for a session so it will not pause; a session past the 1-hour task cap refreshes on an interval |
|
|
111
|
+
|
|
112
|
+
These are still runtime-orchestration primitives, not declarable resources — a Sprite has no desired-state create body to reconcile.
|
|
113
|
+
|
|
102
114
|
## Where it fits
|
|
103
115
|
|
|
104
116
|
The runnable starter is [`examples/sprites-agent-task`](../../examples/sprites-agent-task), which ships both Ops above. Run `chant run agent-task` for the happy path and `chant run guarded-task` to watch the checkpoint-as-compensation rollback. `guarded-task` exits non-zero on purpose: the `Run` phase fails, the `onFailure` `Restore` runs, and the sprite is back at `pre-run`.
|
|
117
|
+
|
|
118
|
+
[`examples/sprites-managed-agent-worker`](../../examples/sprites-managed-agent-worker) composes the config and keep-alive families into one [Claude Managed Agents](https://docs.sprites.dev/integrations/claude-managed-agents/) session: create → egress policy → keep-alive task → env-contract file → runner-as-service → run → release → destroy, with an `onFailure` that frees the hold and tears the Sprite down.
|