@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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite filesystem activities (#848) — imperative file-I/O primitives over the
|
|
3
|
+
* Sprites filesystem API (`/v1/sprites/{id}/fs/*`). Same category as the
|
|
4
|
+
* lifecycle activities in `sprites.ts`: runtime-orchestration primitives, no
|
|
5
|
+
* desired state. They let an Op stage an input file into a sprite and read a
|
|
6
|
+
* result out without shelling it through `spriteExec` + `cat`/`tee`.
|
|
7
|
+
*
|
|
8
|
+
* read/write move raw file bytes in the body (not JSON), so these use a small
|
|
9
|
+
* raw HTTP client (`SpritesRawHttp`) rather than the JSON `SpritesHttp` the
|
|
10
|
+
* lifecycle activities use; path/mode/mkdir/recursive ride as query params.
|
|
11
|
+
* Endpoint + bearer resolution mirror `sprites.ts` — an explicit `endpoint` /
|
|
12
|
+
* `token` wins, then `SPRITES_BASE_URL` / `SPRITES_API_TOKEN`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolveSpritesEndpoint } from "./sprites";
|
|
16
|
+
|
|
17
|
+
function safeJson(text: string): unknown {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(text);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Pure URL builders (unit-testable) ──────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a `/v1/sprites/{id}/fs/{op}?...` URL. `params` values that are undefined
|
|
29
|
+
* or empty are dropped so the query only carries what the caller set. Pure.
|
|
30
|
+
*/
|
|
31
|
+
export function spriteFsUrl(
|
|
32
|
+
base: string,
|
|
33
|
+
id: string,
|
|
34
|
+
op: "read" | "write" | "list" | "delete",
|
|
35
|
+
params: Record<string, string | boolean | undefined>,
|
|
36
|
+
): string {
|
|
37
|
+
const q = new URLSearchParams();
|
|
38
|
+
for (const [k, v] of Object.entries(params)) {
|
|
39
|
+
if (v === undefined || v === "") continue;
|
|
40
|
+
q.set(k, typeof v === "boolean" ? String(v) : v);
|
|
41
|
+
}
|
|
42
|
+
const qs = q.toString();
|
|
43
|
+
return `${base}/v1/sprites/${encodeURIComponent(id)}/fs/${op}${qs ? `?${qs}` : ""}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Raw HTTP client (raw string bodies, mirrors defaultSpritesHttp) ─────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Injectable raw HTTP client — like `SpritesHttp` but the body is sent verbatim
|
|
50
|
+
* (a file's bytes as a string), not JSON-encoded, and the response `text` is the
|
|
51
|
+
* raw body. Tests inject a fake; the default hits `fetch`.
|
|
52
|
+
*/
|
|
53
|
+
export type SpritesRawHttp = (
|
|
54
|
+
method: string,
|
|
55
|
+
url: string,
|
|
56
|
+
body?: string,
|
|
57
|
+
headers?: Record<string, string>,
|
|
58
|
+
signal?: AbortSignal,
|
|
59
|
+
) => Promise<{ status: number; text: string }>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Default `fetch`-based raw client. Sends the body as-is with an
|
|
63
|
+
* `application/octet-stream` content-type and `Authorization: Bearer <token>`
|
|
64
|
+
* when a token is set (real Sprites); the fake ignores the token. The token
|
|
65
|
+
* defaults to `SPRITES_API_TOKEN` at call time. `fetchImpl` is injectable.
|
|
66
|
+
*/
|
|
67
|
+
export function defaultSpritesRawHttp(token?: string, fetchImpl: typeof fetch = fetch): SpritesRawHttp {
|
|
68
|
+
return async (method, url, body, headers, signal) => {
|
|
69
|
+
const h: Record<string, string> = { ...headers };
|
|
70
|
+
if (body !== undefined) h["content-type"] = "application/octet-stream";
|
|
71
|
+
const tok = token ?? process.env.SPRITES_API_TOKEN;
|
|
72
|
+
if (tok) h["authorization"] = `Bearer ${tok}`;
|
|
73
|
+
const res = await fetchImpl(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers: Object.keys(h).length ? h : undefined,
|
|
76
|
+
body,
|
|
77
|
+
signal,
|
|
78
|
+
});
|
|
79
|
+
return { status: res.status, text: await res.text() };
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Activity contracts ──────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
export interface SpriteWriteFileArgs {
|
|
86
|
+
/** Target sprite id (the `name` passed to `spriteCreate`). */
|
|
87
|
+
id: string;
|
|
88
|
+
/** Absolute path (or relative to `workingDir`) to write. */
|
|
89
|
+
path: string;
|
|
90
|
+
/** File contents. */
|
|
91
|
+
content: string;
|
|
92
|
+
/** Octal file mode, e.g. `"0644"`. */
|
|
93
|
+
mode?: string;
|
|
94
|
+
/** Create missing parent directories. */
|
|
95
|
+
mkdir?: boolean;
|
|
96
|
+
/** Base directory for a relative `path`. */
|
|
97
|
+
workingDir?: string;
|
|
98
|
+
endpoint?: string;
|
|
99
|
+
token?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface SpriteReadFileArgs {
|
|
103
|
+
id: string;
|
|
104
|
+
path: string;
|
|
105
|
+
workingDir?: string;
|
|
106
|
+
endpoint?: string;
|
|
107
|
+
token?: string;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SpriteReadFileResult {
|
|
111
|
+
content: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface SpriteListDirArgs {
|
|
115
|
+
id: string;
|
|
116
|
+
path: string;
|
|
117
|
+
workingDir?: string;
|
|
118
|
+
endpoint?: string;
|
|
119
|
+
token?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One directory entry. `type` is `file` or `dir`. */
|
|
123
|
+
export interface SpriteDirEntry {
|
|
124
|
+
name: string;
|
|
125
|
+
type: "file" | "dir";
|
|
126
|
+
size?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface SpriteRemoveArgs {
|
|
130
|
+
id: string;
|
|
131
|
+
path: string;
|
|
132
|
+
/** Remove a directory and its contents. */
|
|
133
|
+
recursive?: boolean;
|
|
134
|
+
/** Perform the delete as root. */
|
|
135
|
+
asRoot?: boolean;
|
|
136
|
+
workingDir?: string;
|
|
137
|
+
endpoint?: string;
|
|
138
|
+
token?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
|
|
142
|
+
|
|
143
|
+
/** Write a file into the sprite. `PUT /v1/sprites/{id}/fs/write` with raw body. */
|
|
144
|
+
export async function spriteWriteFile(
|
|
145
|
+
args: SpriteWriteFileArgs,
|
|
146
|
+
signal?: AbortSignal,
|
|
147
|
+
http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
|
|
148
|
+
): Promise<Record<string, never>> {
|
|
149
|
+
const base = resolveSpritesEndpoint(args);
|
|
150
|
+
const url = spriteFsUrl(base, args.id, "write", {
|
|
151
|
+
path: args.path,
|
|
152
|
+
mode: args.mode,
|
|
153
|
+
mkdir: args.mkdir,
|
|
154
|
+
workingDir: args.workingDir,
|
|
155
|
+
});
|
|
156
|
+
const res = await http("PUT", url, args.content, undefined, signal);
|
|
157
|
+
if (res.status >= 300) {
|
|
158
|
+
throw new Error(`sprite ${args.id} write ${args.path} failed (${res.status}): ${res.text}`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`wrote: sprite/${args.id}:${args.path} (${args.content.length}b)`);
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Read a file from the sprite. `GET /v1/sprites/{id}/fs/read` → raw body. */
|
|
165
|
+
export async function spriteReadFile(
|
|
166
|
+
args: SpriteReadFileArgs,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
|
|
169
|
+
): Promise<SpriteReadFileResult> {
|
|
170
|
+
const base = resolveSpritesEndpoint(args);
|
|
171
|
+
const url = spriteFsUrl(base, args.id, "read", { path: args.path, workingDir: args.workingDir });
|
|
172
|
+
const res = await http("GET", url, undefined, undefined, signal);
|
|
173
|
+
if (res.status === 404) throw new Error(`sprite ${args.id} read ${args.path}: not found`);
|
|
174
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} read ${args.path} failed (${res.status}): ${res.text}`);
|
|
175
|
+
return { content: res.text };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** List a directory in the sprite. `GET /v1/sprites/{id}/fs/list` → entry array. */
|
|
179
|
+
export async function spriteListDir(
|
|
180
|
+
args: SpriteListDirArgs,
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
|
|
183
|
+
): Promise<SpriteDirEntry[]> {
|
|
184
|
+
const base = resolveSpritesEndpoint(args);
|
|
185
|
+
const url = spriteFsUrl(base, args.id, "list", { path: args.path, workingDir: args.workingDir });
|
|
186
|
+
const res = await http("GET", url, undefined, undefined, signal);
|
|
187
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} list ${args.path} failed (${res.status}): ${res.text}`);
|
|
188
|
+
const parsed = safeJson(res.text);
|
|
189
|
+
// Accept a bare array or a `{ entries: [...] }` envelope.
|
|
190
|
+
if (Array.isArray(parsed)) return parsed as SpriteDirEntry[];
|
|
191
|
+
const entries = (parsed as { entries?: unknown } | undefined)?.entries;
|
|
192
|
+
return Array.isArray(entries) ? (entries as SpriteDirEntry[]) : [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Remove a path in the sprite. `DELETE /v1/sprites/{id}/fs/delete`. Idempotent:
|
|
197
|
+
* a 404 (already gone) is a no-op, matching `rm -f` and the destroy activity.
|
|
198
|
+
*/
|
|
199
|
+
export async function spriteRemove(
|
|
200
|
+
args: SpriteRemoveArgs,
|
|
201
|
+
signal?: AbortSignal,
|
|
202
|
+
http: SpritesRawHttp = defaultSpritesRawHttp(args.token),
|
|
203
|
+
): Promise<Record<string, never>> {
|
|
204
|
+
const base = resolveSpritesEndpoint(args);
|
|
205
|
+
const url = spriteFsUrl(base, args.id, "delete", {
|
|
206
|
+
path: args.path,
|
|
207
|
+
recursive: args.recursive,
|
|
208
|
+
asRoot: args.asRoot,
|
|
209
|
+
workingDir: args.workingDir,
|
|
210
|
+
});
|
|
211
|
+
const res = await http("DELETE", url, undefined, undefined, signal);
|
|
212
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
213
|
+
throw new Error(`sprite ${args.id} remove ${args.path} failed (${res.status}): ${res.text}`);
|
|
214
|
+
}
|
|
215
|
+
console.log(`removed: sprite/${args.id}:${args.path}`);
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,15 +7,29 @@ import { SPRITES_CONTRACT, normalizeEndpoint, contractKeys } from "./sprites-con
|
|
|
7
7
|
describe("SPRITES_CONTRACT", () => {
|
|
8
8
|
test("covers every sprite activity that calls the Sprites API", () => {
|
|
9
9
|
const activities = new Set(SPRITES_CONTRACT.map((e) => e.activity));
|
|
10
|
-
//
|
|
10
|
+
// Every activity across the lifecycle, filesystem, config-reconcile, and
|
|
11
|
+
// keep-alive modules that makes an HTTP/WS call.
|
|
11
12
|
expect(activities).toEqual(
|
|
12
13
|
new Set([
|
|
14
|
+
// lifecycle (./sprites.ts)
|
|
13
15
|
"spriteCreate",
|
|
14
16
|
"spriteExec",
|
|
15
17
|
"spriteCheckpoint",
|
|
16
18
|
"listCheckpoints",
|
|
17
19
|
"spriteRestore",
|
|
18
20
|
"spriteDestroy",
|
|
21
|
+
// filesystem (./sprite-fs.ts)
|
|
22
|
+
"spriteWriteFile",
|
|
23
|
+
"spriteReadFile",
|
|
24
|
+
"spriteListDir",
|
|
25
|
+
"spriteRemove",
|
|
26
|
+
// config reconcile (./sprite-config.ts)
|
|
27
|
+
"spriteApplyNetworkPolicy",
|
|
28
|
+
"spriteApplyServices",
|
|
29
|
+
// keep-alive tasks (./sprite-tasks.ts)
|
|
30
|
+
"spriteTaskCreate",
|
|
31
|
+
"spriteTaskRefresh",
|
|
32
|
+
"spriteTaskRelease",
|
|
19
33
|
]),
|
|
20
34
|
);
|
|
21
35
|
});
|
|
@@ -23,7 +37,7 @@ describe("SPRITES_CONTRACT", () => {
|
|
|
23
37
|
test("every entry has a v1 path and a known method", () => {
|
|
24
38
|
for (const e of SPRITES_CONTRACT) {
|
|
25
39
|
expect(e.path.startsWith("/v1/sprites")).toBe(true);
|
|
26
|
-
expect(["GET", "POST", "DELETE", "WS"]).toContain(e.method);
|
|
40
|
+
expect(["GET", "POST", "PUT", "DELETE", "WS"]).toContain(e.method);
|
|
27
41
|
}
|
|
28
42
|
});
|
|
29
43
|
|
|
@@ -43,18 +57,22 @@ describe("SPRITES_CONTRACT", () => {
|
|
|
43
57
|
expect(keys.size).toBe(SPRITES_CONTRACT.length);
|
|
44
58
|
});
|
|
45
59
|
|
|
46
|
-
test("every contract path segment appears in the
|
|
60
|
+
test("every contract path segment appears in the activity source (drift anchor)", () => {
|
|
47
61
|
// Anchors the hand-authored contract to the activity implementations: if an
|
|
48
62
|
// activity's endpoint path changes, a segment goes missing here and the
|
|
49
|
-
// contract must be updated in the same change.
|
|
50
|
-
|
|
63
|
+
// contract must be updated in the same change. Scans every module that owns
|
|
64
|
+
// contract endpoints, not just the lifecycle one.
|
|
65
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
66
|
+
const src = ["sprites.ts", "sprite-fs.ts", "sprite-config.ts", "sprite-tasks.ts"]
|
|
67
|
+
.map((f) => readFileSync(join(dir, f), "utf-8"))
|
|
68
|
+
.join("\n");
|
|
51
69
|
const segments = new Set(
|
|
52
70
|
SPRITES_CONTRACT.flatMap((e) =>
|
|
53
71
|
e.path.split("/").filter((s) => s.length > 0 && !s.startsWith("{")),
|
|
54
72
|
),
|
|
55
73
|
);
|
|
56
74
|
for (const seg of segments) {
|
|
57
|
-
expect(src, `path segment "${seg}" from the contract is absent from
|
|
75
|
+
expect(src, `path segment "${seg}" from the contract is absent from the activity source`).toContain(seg);
|
|
58
76
|
}
|
|
59
77
|
});
|
|
60
78
|
});
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* the fly resource surface generates + drift-checks against. The Sprites API
|
|
6
6
|
* (api.sprites.dev) ships no such spec at any conventional path, so there is
|
|
7
7
|
* nothing to diff automatically. This module is the manual stand-in: the exact
|
|
8
|
-
* endpoint set the fly sprite activities (
|
|
9
|
-
* hand from https://docs.sprites.dev.
|
|
8
|
+
* endpoint set the fly sprite activities (lifecycle, filesystem, config, tasks)
|
|
9
|
+
* depend on, maintained by hand from https://docs.sprites.dev.
|
|
10
10
|
*
|
|
11
11
|
* It anchors two fidelity checks:
|
|
12
12
|
* 1. Coverage — every endpoint here must be served by the pinned spritzer
|
|
@@ -24,24 +24,45 @@
|
|
|
24
24
|
/** One endpoint the fly sprite activities call. */
|
|
25
25
|
export interface SpritesEndpoint {
|
|
26
26
|
/** HTTP method, or "WS" for the control-WebSocket exec channel. */
|
|
27
|
-
method: "GET" | "POST" | "DELETE" | "WS";
|
|
27
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "WS";
|
|
28
28
|
/** Path template under the Sprites base, e.g. `/v1/sprites/{id}/checkpoint`. */
|
|
29
29
|
path: string;
|
|
30
|
-
/** The fly activity that calls it (
|
|
30
|
+
/** The fly activity that calls it (an activity module export). */
|
|
31
31
|
activity: string;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* The Sprites endpoints
|
|
36
|
-
*
|
|
35
|
+
* The Sprites endpoints the fly sprite activities depend on, across the lifecycle
|
|
36
|
+
* (./sprites.ts), filesystem (./sprite-fs.ts), config reconcile
|
|
37
|
+
* (./sprite-config.ts), and keep-alive tasks (./sprite-tasks.ts) modules. Keep in
|
|
38
|
+
* sync with the activity implementations — the unit test asserts every sprite
|
|
39
|
+
* activity is represented, and the coverage test asserts the pinned spritzer
|
|
40
|
+
* serves each one.
|
|
37
41
|
*/
|
|
38
42
|
export const SPRITES_CONTRACT: readonly SpritesEndpoint[] = [
|
|
43
|
+
// Lifecycle (./sprites.ts)
|
|
39
44
|
{ method: "POST", path: "/v1/sprites", activity: "spriteCreate" },
|
|
40
45
|
{ method: "WS", path: "/v1/sprites/{id}/exec", activity: "spriteExec" },
|
|
41
46
|
{ method: "POST", path: "/v1/sprites/{id}/checkpoint", activity: "spriteCheckpoint" },
|
|
42
47
|
{ method: "GET", path: "/v1/sprites/{id}/checkpoints", activity: "listCheckpoints" },
|
|
43
48
|
{ method: "POST", path: "/v1/sprites/{id}/checkpoints/{cp}/restore", activity: "spriteRestore" },
|
|
44
49
|
{ method: "DELETE", path: "/v1/sprites/{id}", activity: "spriteDestroy" },
|
|
50
|
+
// Filesystem (./sprite-fs.ts)
|
|
51
|
+
{ method: "PUT", path: "/v1/sprites/{id}/fs/write", activity: "spriteWriteFile" },
|
|
52
|
+
{ method: "GET", path: "/v1/sprites/{id}/fs/read", activity: "spriteReadFile" },
|
|
53
|
+
{ method: "GET", path: "/v1/sprites/{id}/fs/list", activity: "spriteListDir" },
|
|
54
|
+
{ method: "DELETE", path: "/v1/sprites/{id}/fs/delete", activity: "spriteRemove" },
|
|
55
|
+
// Network policy (./sprite-config.ts)
|
|
56
|
+
{ method: "GET", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
|
|
57
|
+
{ method: "POST", path: "/v1/sprites/{id}/policy/network", activity: "spriteApplyNetworkPolicy" },
|
|
58
|
+
// Services (./sprite-config.ts)
|
|
59
|
+
{ method: "GET", path: "/v1/sprites/{id}/services", activity: "spriteApplyServices" },
|
|
60
|
+
{ method: "PUT", path: "/v1/sprites/{id}/services/{svc}", activity: "spriteApplyServices" },
|
|
61
|
+
{ method: "POST", path: "/v1/sprites/{id}/services/{svc}/start", activity: "spriteApplyServices" },
|
|
62
|
+
// Keep-alive tasks (./sprite-tasks.ts)
|
|
63
|
+
{ method: "POST", path: "/v1/sprites/{id}/tasks", activity: "spriteTaskCreate" },
|
|
64
|
+
{ method: "PUT", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRefresh" },
|
|
65
|
+
{ method: "DELETE", path: "/v1/sprites/{id}/tasks/{name}", activity: "spriteTaskRelease" },
|
|
45
66
|
] as const;
|
|
46
67
|
|
|
47
68
|
/**
|