@intentius/chant-lexicon-fly 0.18.5 → 0.18.7

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,165 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ loadActivities,
4
+ runOpLocally,
5
+ phase,
6
+ spriteCreate,
7
+ spriteApplyNetworkPolicy,
8
+ spriteTaskCreate,
9
+ spriteWriteFile,
10
+ spriteApplyServices,
11
+ spriteExec,
12
+ spriteTaskRelease,
13
+ spriteDestroy,
14
+ type ActivityFn,
15
+ type ActivityProfile,
16
+ type OpConfig,
17
+ } from "@intentius/chant/op";
18
+ import { createSpritesFake } from "./sprites-fake";
19
+ import { spriteCreate as createImpl } from "./sprites";
20
+ import {
21
+ spriteTaskCreate as taskCreateImpl,
22
+ spriteTaskRefresh as taskRefreshImpl,
23
+ spriteTaskRelease as taskReleaseImpl,
24
+ spriteTasksUrl,
25
+ } from "./sprite-tasks";
26
+
27
+ // Keep-alive Tasks (#847) + the full Managed Agents session Op, end-to-end
28
+ // against the in-process fake (S7). No Docker, no key — runs in CI.
29
+
30
+ const PROFILES: Record<string, ActivityProfile> = {
31
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "1ms", backoffCoefficient: 1 } },
32
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 } },
33
+ };
34
+
35
+ let fake: { url: string; close(): Promise<void> };
36
+ let activities: Map<string, ActivityFn>;
37
+ let prevBaseUrl: string | undefined;
38
+
39
+ beforeAll(async () => {
40
+ fake = await createSpritesFake();
41
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
42
+ process.env.SPRITES_BASE_URL = fake.url;
43
+ activities = await loadActivities(["fly"]);
44
+ });
45
+
46
+ afterAll(async () => {
47
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
48
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
49
+ await fake?.close();
50
+ });
51
+
52
+ async function inspect(id: string): Promise<{
53
+ status: string;
54
+ fs: Record<string, string>;
55
+ netPolicy: Array<{ domain: string; action: string }>;
56
+ services: Record<string, { state: { status: string } }>;
57
+ tasks: Record<string, unknown>;
58
+ }> {
59
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
60
+ return (await res.json()) as never;
61
+ }
62
+
63
+ describe("spriteTaskUrl (pure)", () => {
64
+ test("builds the sprite-scoped tasks path", () => {
65
+ expect(spriteTasksUrl("http://h", "s 1")).toBe("http://h/v1/sprites/s%201/tasks");
66
+ });
67
+ });
68
+
69
+ describe("keep-alive task activities", () => {
70
+ test("create → refresh → release, release is idempotent", async () => {
71
+ await createImpl({ name: "ka-1", endpoint: fake.url });
72
+ await taskCreateImpl({ id: "ka-1", name: "session", expire: "5m", endpoint: fake.url });
73
+ expect(Object.keys((await inspect("ka-1")).tasks)).toEqual(["session"]);
74
+
75
+ await taskRefreshImpl({ id: "ka-1", name: "session", expire: "5m", endpoint: fake.url });
76
+ await taskReleaseImpl({ id: "ka-1", name: "session", endpoint: fake.url });
77
+ expect(Object.keys((await inspect("ka-1")).tasks)).toEqual([]);
78
+
79
+ // Idempotent: releasing an already-gone task is a no-op (404 tolerated).
80
+ await expect(taskReleaseImpl({ id: "ka-1", name: "session", endpoint: fake.url })).resolves.toBeDefined();
81
+ });
82
+
83
+ test("refreshing a missing task throws", async () => {
84
+ await createImpl({ name: "ka-2", endpoint: fake.url });
85
+ await expect(taskRefreshImpl({ id: "ka-2", name: "nope", endpoint: fake.url })).rejects.toThrow(/refresh failed/);
86
+ });
87
+
88
+ test("task activities resolve by name", () => {
89
+ for (const fn of ["spriteTaskCreate", "spriteTaskRefresh", "spriteTaskRelease"]) {
90
+ expect(typeof activities.get(fn)).toBe("function");
91
+ }
92
+ });
93
+ });
94
+
95
+ describe("Managed Agents session Op (#847)", () => {
96
+ const SESSION = "agent-session-t1";
97
+ test("Create → Secure → Hold → Stage → Runner → Run → Release → Destroy runs green", async () => {
98
+ const op: OpConfig = {
99
+ name: "managed-agent-session",
100
+ overview: "one session end-to-end",
101
+ taskQueue: "sprites",
102
+ phases: [
103
+ phase("Create", [spriteCreate({ name: SESSION })]),
104
+ phase("Secure", [
105
+ spriteApplyNetworkPolicy({
106
+ id: SESSION,
107
+ rules: [
108
+ { domain: "api.anthropic.com", action: "allow" },
109
+ { domain: "*", action: "deny" },
110
+ ],
111
+ }),
112
+ ]),
113
+ phase("Hold", [spriteTaskCreate({ id: SESSION, name: "session", expire: "5m" })]),
114
+ phase("Stage", [spriteWriteFile({ id: SESSION, path: "/run/agent.env", mkdir: true, content: "ANTHROPIC_SESSION_ID=agent-session-t1" })]),
115
+ phase("Runner", [
116
+ spriteApplyServices({
117
+ id: SESSION,
118
+ start: true,
119
+ services: [{ name: "agent-runner", cmd: "agent-runner", dir: "/run", http_port: 8080 }],
120
+ }),
121
+ ]),
122
+ phase("Run", [spriteExec({ id: SESSION, cmd: "echo session-complete > /run/status" })]),
123
+ phase("Release", [spriteTaskRelease({ id: SESSION, name: "session" })]),
124
+ phase("Destroy", [spriteDestroy({ id: SESSION })]),
125
+ ],
126
+ };
127
+ const result = await runOpLocally(op, activities, PROFILES);
128
+ expect(result.ok).toBe(true);
129
+ expect(result.records.map((r) => r.fn)).toEqual([
130
+ "spriteCreate",
131
+ "spriteApplyNetworkPolicy",
132
+ "spriteTaskCreate",
133
+ "spriteWriteFile",
134
+ "spriteApplyServices",
135
+ "spriteExec",
136
+ "spriteTaskRelease",
137
+ "spriteDestroy",
138
+ ]);
139
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
140
+ });
141
+
142
+ test("mid-session state: policy set, runner running, task held, before teardown", async () => {
143
+ const id = "agent-session-t2";
144
+ const op: OpConfig = {
145
+ name: "managed-agent-session-partial",
146
+ overview: "up to Run, no teardown, so state is observable",
147
+ phases: [
148
+ phase("Create", [spriteCreate({ name: id })]),
149
+ phase("Secure", [
150
+ spriteApplyNetworkPolicy({ id, rules: [{ domain: "api.anthropic.com", action: "allow" }, { domain: "*", action: "deny" }] }),
151
+ ]),
152
+ phase("Hold", [spriteTaskCreate({ id, name: "session", expire: "5m" })]),
153
+ phase("Runner", [spriteApplyServices({ id, start: true, services: [{ name: "agent-runner", cmd: "agent-runner" }] })]),
154
+ ],
155
+ };
156
+ await runOpLocally(op, activities, PROFILES);
157
+ const s = await inspect(id);
158
+ expect(s.netPolicy).toEqual([
159
+ { domain: "api.anthropic.com", action: "allow" },
160
+ { domain: "*", action: "deny" },
161
+ ]);
162
+ expect(s.services["agent-runner"].state.status).toBe("running");
163
+ expect(Object.keys(s.tasks)).toEqual(["session"]);
164
+ });
165
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Sprite keep-alive Tasks activities (#847) — a hold that prevents a Sprite from
3
+ * pausing while a session runs. While at least one task exists the Sprite stays
4
+ * active; the hold is refreshed on an interval and released on exit.
5
+ *
6
+ * Per the Sprites docs a task carries an `expire` (seconds or a duration string
7
+ * like `"5m"`/`"1h"`) with a **1-hour max per task**, so a session longer than
8
+ * that must refresh. The recommended shape is a short expiry refreshed on a
9
+ * shorter interval — 5-minute expiry / 60-second refresh — released on exit.
10
+ *
11
+ * These are the three primitives, not a magic looping hold: a phased Op creates
12
+ * the task around a session (`spriteTaskCreate`) and releases it after (in the
13
+ * happy path and in `onFailure`). A worker whose session can outlast the 1-hour
14
+ * cap wraps its own run in a `spriteTaskRefresh` loop (that ambient loop lives in
15
+ * the long-running caller, not a single serializable activity — a phased Op has
16
+ * no single step to hang it on). `spriteTaskRelease` is idempotent so a crash
17
+ * still frees the Sprite (and the task auto-expires if the release never lands).
18
+ *
19
+ * The task REST path is provisional (S6, #766); it mirrors the other endpoints'
20
+ * `/v1/sprites/{id}/...` shape. URL building is a pure helper so the path can
21
+ * move without touching callers.
22
+ */
23
+
24
+ import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
25
+
26
+ /** The task REST base for a sprite. Pure. */
27
+ export function spriteTasksUrl(base: string, id: string): string {
28
+ return `${base}/v1/sprites/${encodeURIComponent(id)}/tasks`;
29
+ }
30
+ export function spriteTaskUrl(base: string, id: string, name: string): string {
31
+ return `${spriteTasksUrl(base, id)}/${encodeURIComponent(name)}`;
32
+ }
33
+
34
+ export interface SpriteTaskCreateArgs {
35
+ id: string;
36
+ /** Task name (the key later refresh/release use). */
37
+ name: string;
38
+ /** Expiry: seconds (number) or a duration string (`"5m"`, `"1h"`). Max 1h. */
39
+ expire?: number | string;
40
+ endpoint?: string;
41
+ token?: string;
42
+ }
43
+
44
+ export interface SpriteTaskRefreshArgs extends SpriteTaskCreateArgs {}
45
+
46
+ export interface SpriteTaskReleaseArgs {
47
+ id: string;
48
+ name: string;
49
+ endpoint?: string;
50
+ token?: string;
51
+ }
52
+
53
+ /** Create a keep-alive task. `POST /v1/sprites/{id}/tasks`. */
54
+ export async function spriteTaskCreate(
55
+ args: SpriteTaskCreateArgs,
56
+ signal?: AbortSignal,
57
+ http: SpritesHttp = defaultSpritesHttp(args.token),
58
+ ): Promise<{ name: string }> {
59
+ const base = resolveSpritesEndpoint(args);
60
+ const body = { name: args.name, ...(args.expire !== undefined ? { expire: args.expire } : {}) };
61
+ const res = await http("POST", spriteTasksUrl(base, args.id), body, undefined, signal);
62
+ if (res.status >= 300) throw new Error(`sprite ${args.id} task create failed (${res.status}): ${res.text}`);
63
+ return { name: args.name };
64
+ }
65
+
66
+ /** Refresh a task's expiry. `PUT /v1/sprites/{id}/tasks/{name}`. */
67
+ export async function spriteTaskRefresh(
68
+ args: SpriteTaskRefreshArgs,
69
+ signal?: AbortSignal,
70
+ http: SpritesHttp = defaultSpritesHttp(args.token),
71
+ ): Promise<{ name: string }> {
72
+ const base = resolveSpritesEndpoint(args);
73
+ const body = args.expire !== undefined ? { expire: args.expire } : undefined;
74
+ const res = await http("PUT", spriteTaskUrl(base, args.id, args.name), body, undefined, signal);
75
+ if (res.status >= 300) throw new Error(`sprite ${args.id} task refresh failed (${res.status}): ${res.text}`);
76
+ return { name: args.name };
77
+ }
78
+
79
+ /** Release a task (idempotent; a 404 means already gone). `DELETE /v1/sprites/{id}/tasks/{name}`. */
80
+ export async function spriteTaskRelease(
81
+ args: SpriteTaskReleaseArgs,
82
+ signal?: AbortSignal,
83
+ http: SpritesHttp = defaultSpritesHttp(args.token),
84
+ ): Promise<Record<string, never>> {
85
+ const base = resolveSpritesEndpoint(args);
86
+ const res = await http("DELETE", spriteTaskUrl(base, args.id, args.name), undefined, undefined, signal);
87
+ if (res.status >= 300 && res.status !== 404) {
88
+ throw new Error(`sprite ${args.id} task release failed (${res.status}): ${res.text}`);
89
+ }
90
+ return {};
91
+ }
@@ -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(/^echo\s+(.+?)\s*>\s*(\S+)$/))) {
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.