@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.
Files changed (30) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/op/activities/emulator-images.d.ts +1 -1
  4. package/dist/op/activities/index.d.ts +6 -0
  5. package/dist/op/activities/index.d.ts.map +1 -1
  6. package/dist/op/activities/sprite-config.d.ts +91 -0
  7. package/dist/op/activities/sprite-config.d.ts.map +1 -0
  8. package/dist/op/activities/sprite-fs.d.ts +96 -0
  9. package/dist/op/activities/sprite-fs.d.ts.map +1 -0
  10. package/dist/op/activities/sprite-tasks.d.ts +54 -0
  11. package/dist/op/activities/sprite-tasks.d.ts.map +1 -0
  12. package/dist/op/activities/sprites-contract.d.ts +10 -6
  13. package/dist/op/activities/sprites-contract.d.ts.map +1 -1
  14. package/dist/op/activities/sprites-fake.d.ts +37 -0
  15. package/dist/op/activities/sprites-fake.d.ts.map +1 -1
  16. package/package.json +2 -2
  17. package/src/index.ts +9 -0
  18. package/src/op/activities/emulator-images.ts +1 -1
  19. package/src/op/activities/index.ts +56 -0
  20. package/src/op/activities/sprite-config.docker.integration.test.ts +98 -0
  21. package/src/op/activities/sprite-config.test.ts +159 -0
  22. package/src/op/activities/sprite-config.ts +246 -0
  23. package/src/op/activities/sprite-fs.test.ts +136 -0
  24. package/src/op/activities/sprite-fs.ts +217 -0
  25. package/src/op/activities/sprite-tasks.test.ts +165 -0
  26. package/src/op/activities/sprite-tasks.ts +91 -0
  27. package/src/op/activities/sprites-contract.test.ts +24 -6
  28. package/src/op/activities/sprites-contract.ts +27 -6
  29. package/src/op/activities/sprites-fake.ts +201 -2
  30. 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(/^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.