@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
@@ -0,0 +1,159 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import { loadActivities, type ActivityFn } from "@intentius/chant/op";
3
+ import { createSpritesFake } from "./sprites-fake";
4
+ import { spriteCreate as createImpl } from "./sprites";
5
+ import {
6
+ spriteApplyNetworkPolicy,
7
+ spriteApplyServices,
8
+ validateNetworkRules,
9
+ networkRulesEqual,
10
+ validateServices,
11
+ serviceConfigEqual,
12
+ type NetworkRule,
13
+ type ServiceSpec,
14
+ } from "./sprite-config";
15
+
16
+ // Config reconcile (#849) — pure validators + end-to-end against the in-process
17
+ // fake (S7), no Docker. Network policy is a whole-object replace; services are
18
+ // additive + update, started in dependency order.
19
+
20
+ let fake: { url: string; close(): Promise<void> };
21
+ let prevBaseUrl: string | undefined;
22
+
23
+ beforeAll(async () => {
24
+ fake = await createSpritesFake();
25
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
26
+ process.env.SPRITES_BASE_URL = fake.url;
27
+ });
28
+
29
+ afterAll(async () => {
30
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
31
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
32
+ await fake?.close();
33
+ });
34
+
35
+ async function inspect(id: string): Promise<{
36
+ netPolicy: NetworkRule[];
37
+ services: Record<string, { name: string; needs?: string[]; state: { status: string } }>;
38
+ }> {
39
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
40
+ return (await res.json()) as never;
41
+ }
42
+
43
+ describe("network policy validators (pure)", () => {
44
+ test("validateNetworkRules rejects a bad action / empty domain", () => {
45
+ expect(() => validateNetworkRules([{ domain: "", action: "allow" }])).toThrow(/missing domain/);
46
+ expect(() => validateNetworkRules([{ domain: "x", action: "nope" as never }])).toThrow(/allow.*deny/);
47
+ expect(() => validateNetworkRules([{ domain: "github.com", action: "allow" }])).not.toThrow();
48
+ });
49
+ test("networkRulesEqual is order-sensitive", () => {
50
+ const a: NetworkRule[] = [{ domain: "a", action: "allow" }, { domain: "b", action: "deny" }];
51
+ expect(networkRulesEqual(a, [...a])).toBe(true);
52
+ expect(networkRulesEqual(a, [a[1], a[0]])).toBe(false);
53
+ expect(networkRulesEqual(a, [a[0]])).toBe(false);
54
+ });
55
+ });
56
+
57
+ describe("service validators (pure)", () => {
58
+ test("validateServices returns a dependency-first order", () => {
59
+ const svcs: ServiceSpec[] = [
60
+ { name: "web", cmd: "run-web", needs: ["db"] },
61
+ { name: "db", cmd: "run-db" },
62
+ ];
63
+ expect(validateServices(svcs)).toEqual(["db", "web"]);
64
+ });
65
+ test("rejects a dangling needs and a cycle and a duplicate", () => {
66
+ expect(() => validateServices([{ name: "web", cmd: "x", needs: ["db"] }])).toThrow(/not defined/);
67
+ expect(() =>
68
+ validateServices([
69
+ { name: "a", cmd: "x", needs: ["b"] },
70
+ { name: "b", cmd: "x", needs: ["a"] },
71
+ ]),
72
+ ).toThrow(/cycle/);
73
+ expect(() =>
74
+ validateServices([
75
+ { name: "a", cmd: "x" },
76
+ { name: "a", cmd: "y" },
77
+ ]),
78
+ ).toThrow(/duplicate/);
79
+ });
80
+ test("serviceConfigEqual ignores undefined-vs-empty noise", () => {
81
+ expect(serviceConfigEqual({ cmd: "x" }, { cmd: "x", args: [], env: {} })).toBe(true);
82
+ expect(serviceConfigEqual({ cmd: "x", http_port: 80 }, { cmd: "x" })).toBe(false);
83
+ });
84
+ });
85
+
86
+ describe("spriteApplyNetworkPolicy reconcile", () => {
87
+ test("applies once, converges on re-apply, replaces on change", async () => {
88
+ await createImpl({ name: "np-1", endpoint: fake.url });
89
+ const rules: NetworkRule[] = [
90
+ { domain: "*.github.com", action: "allow" },
91
+ { domain: "*", action: "deny" },
92
+ ];
93
+ expect((await spriteApplyNetworkPolicy({ id: "np-1", rules, endpoint: fake.url })).changed).toBe(true);
94
+ expect((await inspect("np-1")).netPolicy).toEqual(rules);
95
+
96
+ // Idempotent: same ruleset → no change.
97
+ expect((await spriteApplyNetworkPolicy({ id: "np-1", rules, endpoint: fake.url })).changed).toBe(false);
98
+
99
+ // Changed ruleset → replaced.
100
+ const tighter: NetworkRule[] = [{ domain: "github.com", action: "allow" }, { domain: "*", action: "deny" }];
101
+ expect((await spriteApplyNetworkPolicy({ id: "np-1", rules: tighter, endpoint: fake.url })).changed).toBe(true);
102
+ expect((await inspect("np-1")).netPolicy).toEqual(tighter);
103
+ });
104
+
105
+ test("invalid rules throw before any HTTP", async () => {
106
+ await expect(
107
+ spriteApplyNetworkPolicy({ id: "np-1", rules: [{ domain: "", action: "allow" }], endpoint: fake.url }),
108
+ ).rejects.toThrow(/missing domain/);
109
+ });
110
+ });
111
+
112
+ describe("spriteApplyServices reconcile", () => {
113
+ test("creates services, starts them in dependency order, converges on re-apply", async () => {
114
+ await createImpl({ name: "svc-1", endpoint: fake.url });
115
+ const services: ServiceSpec[] = [
116
+ { name: "web", cmd: "run-web", needs: ["db"], http_port: 8080 },
117
+ { name: "db", cmd: "run-db" },
118
+ ];
119
+ const r1 = await spriteApplyServices({ id: "svc-1", services, start: true, endpoint: fake.url });
120
+ expect(new Set(r1.applied)).toEqual(new Set(["web", "db"]));
121
+ // Started dependency-first.
122
+ expect(r1.started).toEqual(["db", "web"]);
123
+
124
+ const state = await inspect("svc-1");
125
+ expect(Object.keys(state.services).sort()).toEqual(["db", "web"]);
126
+ expect(state.services.web.state.status).toBe("running");
127
+
128
+ // Re-apply unchanged → nothing applied.
129
+ const r2 = await spriteApplyServices({ id: "svc-1", services, endpoint: fake.url });
130
+ expect(r2.applied).toEqual([]);
131
+
132
+ // Change one → only it is re-applied.
133
+ const changed = services.map((s) => (s.name === "web" ? { ...s, http_port: 9090 } : s));
134
+ const r3 = await spriteApplyServices({ id: "svc-1", services: changed, endpoint: fake.url });
135
+ expect(r3.applied).toEqual(["web"]);
136
+ });
137
+
138
+ test("a dependency cycle throws before any HTTP", async () => {
139
+ await createImpl({ name: "svc-2", endpoint: fake.url });
140
+ await expect(
141
+ spriteApplyServices({
142
+ id: "svc-2",
143
+ services: [
144
+ { name: "a", cmd: "x", needs: ["b"] },
145
+ { name: "b", cmd: "x", needs: ["a"] },
146
+ ],
147
+ endpoint: fake.url,
148
+ }),
149
+ ).rejects.toThrow(/cycle/);
150
+ });
151
+ });
152
+
153
+ describe("config activities resolve by name", () => {
154
+ test("loadActivities([\"fly\"]) exposes the reconcile activities", async () => {
155
+ const activities: Map<string, ActivityFn> = await loadActivities(["fly"]);
156
+ expect(typeof activities.get("spriteApplyNetworkPolicy")).toBe("function");
157
+ expect(typeof activities.get("spriteApplyServices")).toBe("function");
158
+ });
159
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Sprite config reconcile activities (#849) — the two pieces of genuine
3
+ * desired-state a Sprite carries: its outbound network policy and its background
4
+ * services. Unlike the sprite itself (an Op primitive with no reconcilable
5
+ * create body), these are set after create and persist across cold boots, so
6
+ * chant reconciles typed config against the live Sprite — the `flyApply`
7
+ * plan→CRUD pattern scoped to one Sprite, not a declarable resource.
8
+ *
9
+ * Both use the JSON `SpritesHttp` client from `sprites.ts`; endpoint + bearer
10
+ * resolution is shared. Validation is pure and runs before any HTTP (invalid
11
+ * rules / a `needs` cycle throw up front), so a bad config fails the step
12
+ * cleanly rather than half-applying.
13
+ *
14
+ * Scope (v1): network policy is a whole-object replace (converges by
15
+ * construction); services reconcile is additive + update (create-or-update each
16
+ * desired service, optionally start). Owned-only prune of stale services is out
17
+ * of scope — the documented Services REST surface exposes no delete.
18
+ */
19
+
20
+ import { resolveSpritesEndpoint, defaultSpritesHttp, type SpritesHttp } from "./sprites";
21
+
22
+ function safeJson(text: string): unknown {
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ // ── Network policy ──────────────────────────────────────────────────────────────
31
+
32
+ /** One outbound rule. Ordered — specificity is positional, so order is significant. */
33
+ export interface NetworkRule {
34
+ domain: string;
35
+ action: "allow" | "deny";
36
+ }
37
+
38
+ export interface SpriteApplyNetworkPolicyArgs {
39
+ id: string;
40
+ /** The complete desired ruleset (whole-object replace). */
41
+ rules: NetworkRule[];
42
+ endpoint?: string;
43
+ token?: string;
44
+ }
45
+
46
+ export interface SpriteApplyNetworkPolicyResult {
47
+ /** True when the live policy differed and was replaced; false when already converged. */
48
+ changed: boolean;
49
+ }
50
+
51
+ /**
52
+ * Validate a ruleset: every rule needs a non-empty `domain` and an `allow`/`deny`
53
+ * `action`. Pure; throws on the first offender.
54
+ */
55
+ export function validateNetworkRules(rules: NetworkRule[]): void {
56
+ rules.forEach((r, i) => {
57
+ if (!r || typeof r.domain !== "string" || r.domain.trim() === "") {
58
+ throw new Error(`network rule ${i}: missing domain`);
59
+ }
60
+ if (r.action !== "allow" && r.action !== "deny") {
61
+ throw new Error(`network rule ${i} (${r.domain}): action must be "allow" or "deny", got ${JSON.stringify(r.action)}`);
62
+ }
63
+ });
64
+ }
65
+
66
+ /** Order-sensitive equality of two rulesets (position is significant). Pure. */
67
+ export function networkRulesEqual(a: NetworkRule[], b: NetworkRule[]): boolean {
68
+ if (a.length !== b.length) return false;
69
+ return a.every((r, i) => r.domain === b[i].domain && r.action === b[i].action);
70
+ }
71
+
72
+ const policyUrl = (base: string, id: string): string =>
73
+ `${base}/v1/sprites/${encodeURIComponent(id)}/policy/network`;
74
+
75
+ /**
76
+ * Reconcile a Sprite's outbound network policy. GET the live ruleset, and POST
77
+ * the desired set only when it differs (`GET`/`POST /policy/network`). Returns
78
+ * whether a change was applied.
79
+ */
80
+ export async function spriteApplyNetworkPolicy(
81
+ args: SpriteApplyNetworkPolicyArgs,
82
+ signal?: AbortSignal,
83
+ http: SpritesHttp = defaultSpritesHttp(args.token),
84
+ ): Promise<SpriteApplyNetworkPolicyResult> {
85
+ validateNetworkRules(args.rules);
86
+ const base = resolveSpritesEndpoint(args);
87
+ const url = policyUrl(base, args.id);
88
+
89
+ const cur = await http("GET", url, undefined, undefined, signal);
90
+ if (cur.status >= 300) throw new Error(`sprite ${args.id} get policy failed (${cur.status}): ${cur.text}`);
91
+ const live = (safeJson(cur.text) as { rules?: NetworkRule[] } | undefined)?.rules ?? [];
92
+ if (networkRulesEqual(live, args.rules)) {
93
+ console.log(`policy: sprite/${args.id} already converged (${args.rules.length} rules)`);
94
+ return { changed: false };
95
+ }
96
+
97
+ const res = await http("POST", url, { rules: args.rules }, undefined, signal);
98
+ if (res.status >= 300) throw new Error(`sprite ${args.id} set policy failed (${res.status}): ${res.text}`);
99
+ console.log(`policy: sprite/${args.id} applied ${args.rules.length} rules (${base})`);
100
+ return { changed: true };
101
+ }
102
+
103
+ // ── Services ─────────────────────────────────────────────────────────────────────
104
+
105
+ /** A desired background service. Keyed by `name`; PUT is create-or-update. */
106
+ export interface ServiceSpec {
107
+ name: string;
108
+ cmd: string;
109
+ args?: string[];
110
+ env?: Record<string, string>;
111
+ dir?: string;
112
+ /** Names of services that must start first. */
113
+ needs?: string[];
114
+ /** Route the Sprite's public URL to this port. */
115
+ http_port?: number;
116
+ }
117
+
118
+ export interface SpriteApplyServicesArgs {
119
+ id: string;
120
+ services: ServiceSpec[];
121
+ /** Start each service after applying (in dependency order). Default: false. */
122
+ start?: boolean;
123
+ endpoint?: string;
124
+ token?: string;
125
+ }
126
+
127
+ export interface SpriteApplyServicesResult {
128
+ /** Names that were created or updated (converged services are skipped). */
129
+ applied: string[];
130
+ /** Names that were started (empty unless `start`). */
131
+ started: string[];
132
+ }
133
+
134
+ /**
135
+ * Validate a service set: names are unique, every `needs` target exists, and the
136
+ * dependency graph is acyclic. Pure; throws on the first violation. Returns the
137
+ * names in a valid start order (dependencies first).
138
+ */
139
+ export function validateServices(services: ServiceSpec[]): string[] {
140
+ const byName = new Map<string, ServiceSpec>();
141
+ for (const s of services) {
142
+ if (!s.name) throw new Error("service: missing name");
143
+ if (byName.has(s.name)) throw new Error(`service ${s.name}: duplicate name`);
144
+ byName.set(s.name, s);
145
+ }
146
+ for (const s of services) {
147
+ for (const dep of s.needs ?? []) {
148
+ if (!byName.has(dep)) throw new Error(`service ${s.name}: needs "${dep}" which is not defined`);
149
+ }
150
+ }
151
+ // Topological order via DFS; a back-edge is a cycle.
152
+ const order: string[] = [];
153
+ const state = new Map<string, "visiting" | "done">();
154
+ const visit = (name: string, trail: string[]): void => {
155
+ const st = state.get(name);
156
+ if (st === "done") return;
157
+ if (st === "visiting") throw new Error(`service dependency cycle: ${[...trail, name].join(" -> ")}`);
158
+ state.set(name, "visiting");
159
+ for (const dep of byName.get(name)!.needs ?? []) visit(dep, [...trail, name]);
160
+ state.set(name, "done");
161
+ order.push(name);
162
+ };
163
+ for (const s of services) visit(s.name, []);
164
+ return order;
165
+ }
166
+
167
+ /** Comparable service config (the fields the reconcile diffs on). Pure. */
168
+ export function serviceConfigEqual(a: Partial<ServiceSpec>, b: Partial<ServiceSpec>): boolean {
169
+ const norm = (s: Partial<ServiceSpec>): string =>
170
+ JSON.stringify({
171
+ cmd: s.cmd ?? "",
172
+ args: s.args ?? [],
173
+ env: s.env ?? {},
174
+ dir: s.dir ?? "",
175
+ needs: s.needs ?? [],
176
+ http_port: s.http_port ?? null,
177
+ });
178
+ return norm(a) === norm(b);
179
+ }
180
+
181
+ const servicesUrl = (base: string, id: string): string =>
182
+ `${base}/v1/sprites/${encodeURIComponent(id)}/services`;
183
+ const serviceUrl = (base: string, id: string, svc: string): string =>
184
+ `${servicesUrl(base, id)}/${encodeURIComponent(svc)}`;
185
+
186
+ function serviceBody(s: ServiceSpec): Record<string, unknown> {
187
+ return {
188
+ cmd: s.cmd,
189
+ ...(s.args ? { args: s.args } : {}),
190
+ ...(s.env ? { env: s.env } : {}),
191
+ ...(s.dir ? { dir: s.dir } : {}),
192
+ ...(s.needs ? { needs: s.needs } : {}),
193
+ ...(s.http_port !== undefined ? { http_port: s.http_port } : {}),
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Reconcile a Sprite's background services (additive + update). Lists the live
199
+ * services, then `PUT`s each desired service that is new or changed
200
+ * (create-or-update by name); when `start` is set, `POST .../start`s them in
201
+ * dependency order. Converged services are skipped. Owned-only prune is out of
202
+ * scope (no delete in the Services REST surface).
203
+ */
204
+ export async function spriteApplyServices(
205
+ args: SpriteApplyServicesArgs,
206
+ signal?: AbortSignal,
207
+ http: SpritesHttp = defaultSpritesHttp(args.token),
208
+ ): Promise<SpriteApplyServicesResult> {
209
+ const startOrder = validateServices(args.services);
210
+ const base = resolveSpritesEndpoint(args);
211
+ const byName = new Map(args.services.map((s) => [s.name, s]));
212
+
213
+ // Live services, keyed by name, for the create-vs-update diff.
214
+ const listRes = await http("GET", servicesUrl(base, args.id), undefined, undefined, signal);
215
+ if (listRes.status >= 300) throw new Error(`sprite ${args.id} list services failed (${listRes.status}): ${listRes.text}`);
216
+ const liveList = safeJson(listRes.text);
217
+ const live = new Map<string, Partial<ServiceSpec>>();
218
+ if (Array.isArray(liveList)) {
219
+ for (const s of liveList as Array<Partial<ServiceSpec> & { name?: string }>) {
220
+ if (s.name) live.set(s.name, s);
221
+ }
222
+ }
223
+
224
+ const applied: string[] = [];
225
+ for (const s of args.services) {
226
+ const cur = live.get(s.name);
227
+ if (cur && serviceConfigEqual(cur, s)) continue; // already converged
228
+ const res = await http("PUT", serviceUrl(base, args.id, s.name), serviceBody(s), undefined, signal);
229
+ if (res.status >= 300) throw new Error(`sprite ${args.id} apply service ${s.name} failed (${res.status}): ${res.text}`);
230
+ applied.push(s.name);
231
+ }
232
+ console.log(`services: sprite/${args.id} applied ${applied.length}/${args.services.length} (${base})`);
233
+
234
+ const started: string[] = [];
235
+ if (args.start) {
236
+ for (const name of startOrder) {
237
+ if (!byName.has(name)) continue;
238
+ const res = await http("POST", `${serviceUrl(base, args.id, name)}/start`, undefined, undefined, signal);
239
+ if (res.status >= 300) throw new Error(`sprite ${args.id} start service ${name} failed (${res.status}): ${res.text}`);
240
+ started.push(name);
241
+ }
242
+ console.log(`services: sprite/${args.id} started ${started.length} in dependency order`);
243
+ }
244
+
245
+ return { applied, started };
246
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from "vitest";
2
+ import {
3
+ loadActivities,
4
+ runOpLocally,
5
+ phase,
6
+ spriteCreate,
7
+ spriteExec,
8
+ spriteWriteFile,
9
+ spriteReadFile,
10
+ type ActivityFn,
11
+ type ActivityProfile,
12
+ type OpConfig,
13
+ } from "@intentius/chant/op";
14
+ import { createSpritesFake } from "./sprites-fake";
15
+ import { spriteCreate as createImpl } from "./sprites";
16
+ import {
17
+ spriteWriteFile as writeImpl,
18
+ spriteReadFile as readImpl,
19
+ spriteListDir as listImpl,
20
+ spriteRemove as removeImpl,
21
+ spriteFsUrl,
22
+ } from "./sprite-fs";
23
+
24
+ // Filesystem activities (#848) end-to-end against the in-process fake (S7) — no
25
+ // Docker, runs in CI. Direct-impl calls cover the return values; an Op-level
26
+ // stage → process → collect proves they resolve by name and compose.
27
+
28
+ const PROFILES: Record<string, ActivityProfile> = {
29
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 3, initialInterval: "1ms", backoffCoefficient: 1 } },
30
+ fastIdempotent: { startToCloseTimeout: "5m", retry: { maximumAttempts: 2, initialInterval: "1ms", backoffCoefficient: 1 } },
31
+ };
32
+
33
+ let fake: { url: string; close(): Promise<void> };
34
+ let activities: Map<string, ActivityFn>;
35
+ let prevBaseUrl: string | undefined;
36
+
37
+ beforeAll(async () => {
38
+ fake = await createSpritesFake();
39
+ prevBaseUrl = process.env.SPRITES_BASE_URL;
40
+ process.env.SPRITES_BASE_URL = fake.url;
41
+ activities = await loadActivities(["fly"]);
42
+ });
43
+
44
+ afterAll(async () => {
45
+ if (prevBaseUrl === undefined) delete process.env.SPRITES_BASE_URL;
46
+ else process.env.SPRITES_BASE_URL = prevBaseUrl;
47
+ await fake?.close();
48
+ });
49
+
50
+ async function inspect(id: string): Promise<{ fs: Record<string, string> }> {
51
+ const res = await fetch(`${fake.url}/v1/sprites/${id}`);
52
+ return (await res.json()) as { fs: Record<string, string> };
53
+ }
54
+
55
+ describe("spriteFsUrl (pure)", () => {
56
+ test("builds a query string, dropping unset params", () => {
57
+ expect(spriteFsUrl("http://h", "s 1", "write", { path: "/a", mkdir: true, mode: undefined, workingDir: "" })).toBe(
58
+ "http://h/v1/sprites/s%201/fs/write?path=%2Fa&mkdir=true",
59
+ );
60
+ });
61
+ test("no params → no query", () => {
62
+ expect(spriteFsUrl("http://h", "s", "read", {})).toBe("http://h/v1/sprites/s/fs/read");
63
+ });
64
+ });
65
+
66
+ describe("fs activities resolve by name", () => {
67
+ test("loadActivities([\"fly\"]) exposes the four fs activities", () => {
68
+ for (const fn of ["spriteWriteFile", "spriteReadFile", "spriteListDir", "spriteRemove"]) {
69
+ expect(typeof activities.get(fn)).toBe("function");
70
+ }
71
+ });
72
+ });
73
+
74
+ describe("write → read → list → remove round-trip", () => {
75
+ test("round-trips against the fake, remove is idempotent", async () => {
76
+ await createImpl({ name: "fs-1", endpoint: fake.url });
77
+
78
+ await writeImpl({ id: "fs-1", path: "/work/input", content: "hello", endpoint: fake.url });
79
+ expect((await readImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).content).toBe("hello");
80
+
81
+ await writeImpl({ id: "fs-1", path: "/work/notes/a.txt", content: "x", endpoint: fake.url });
82
+ const list = await listImpl({ id: "fs-1", path: "/work", endpoint: fake.url });
83
+ expect(list).toEqual(
84
+ expect.arrayContaining([
85
+ { name: "notes", type: "dir" },
86
+ { name: "input", type: "file", size: 5 },
87
+ ]),
88
+ );
89
+
90
+ await removeImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url });
91
+ await expect(readImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).rejects.toThrow(/not found/);
92
+ // Idempotent: removing an already-gone path is a no-op (404 tolerated).
93
+ await expect(removeImpl({ id: "fs-1", path: "/work/input", endpoint: fake.url })).resolves.toBeDefined();
94
+ });
95
+
96
+ test("reading a missing file throws not-found", async () => {
97
+ await createImpl({ name: "fs-2", endpoint: fake.url });
98
+ await expect(readImpl({ id: "fs-2", path: "/nope", endpoint: fake.url })).rejects.toThrow(/not found/);
99
+ });
100
+
101
+ test("recursive remove clears a whole subtree", async () => {
102
+ await createImpl({ name: "fs-3", endpoint: fake.url });
103
+ await writeImpl({ id: "fs-3", path: "/d/a", content: "1", endpoint: fake.url });
104
+ await writeImpl({ id: "fs-3", path: "/d/sub/b", content: "2", endpoint: fake.url });
105
+ await removeImpl({ id: "fs-3", path: "/d", recursive: true, endpoint: fake.url });
106
+ expect(await listImpl({ id: "fs-3", path: "/d", endpoint: fake.url })).toEqual([]);
107
+ });
108
+ });
109
+
110
+ describe("Op-level stage → process → collect (the example flow)", () => {
111
+ test("write input, exec copies it, read output — runs green by-name", async () => {
112
+ const op: OpConfig = {
113
+ name: "fs-agent-task",
114
+ overview: "stage an input file, process it, collect the result",
115
+ taskQueue: "sprites",
116
+ phases: [
117
+ phase("Create", [spriteCreate({ name: "fs-op-1" })]),
118
+ phase("Stage", [spriteWriteFile({ id: "fs-op-1", path: "/work/input", content: "hello" })]),
119
+ phase("Run", [spriteExec({ id: "fs-op-1", cmd: "cat /work/input > /work/output" })]),
120
+ phase("Collect", [spriteReadFile({ id: "fs-op-1", path: "/work/output" })]),
121
+ ],
122
+ };
123
+ const result = await runOpLocally(op, activities, PROFILES);
124
+ expect(result.ok).toBe(true);
125
+ expect(result.records.map((r) => r.fn)).toEqual([
126
+ "spriteCreate",
127
+ "spriteWriteFile",
128
+ "spriteExec",
129
+ "spriteReadFile",
130
+ ]);
131
+ expect(result.records.every((r) => r.status === "ok")).toBe(true);
132
+
133
+ const state = await inspect("fs-op-1");
134
+ expect(state.fs["/work/output"]).toBe("hello");
135
+ });
136
+ });