@intentius/chant-lexicon-fly 0.16.0 → 0.18.0
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/README.md +2 -2
- package/dist/composites/fly-deploy.d.ts +1 -1
- package/dist/composites/fly-deploy.d.ts.map +1 -1
- package/dist/coverage.d.ts +15 -0
- package/dist/coverage.d.ts.map +1 -0
- package/dist/emulator-freshness-cli.d.ts +11 -0
- package/dist/emulator-freshness-cli.d.ts.map +1 -0
- package/dist/emulator-freshness.d.ts +38 -0
- package/dist/emulator-freshness.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/mcp/context-tools.d.ts +15 -0
- package/dist/mcp/context-tools.d.ts.map +1 -0
- package/dist/op/activities/emulator-images.d.ts +20 -0
- package/dist/op/activities/emulator-images.d.ts.map +1 -0
- package/dist/op/activities/flaps.d.ts +1 -1
- package/dist/op/activities/flaps.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/machines-contract.d.ts +36 -0
- package/dist/op/activities/machines-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-contract.d.ts +45 -0
- package/dist/op/activities/sprites-contract.d.ts.map +1 -0
- package/dist/op/activities/sprites-emulator.d.ts +29 -0
- package/dist/op/activities/sprites-emulator.d.ts.map +1 -0
- package/dist/op/activities/sprites-fake.d.ts +62 -0
- package/dist/op/activities/sprites-fake.d.ts.map +1 -0
- package/dist/op/activities/sprites.d.ts +195 -0
- package/dist/op/activities/sprites.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reference-catalog.d.ts +21 -0
- package/dist/reference-catalog.d.ts.map +1 -0
- package/package.json +6 -2
- package/src/composites/fly-deploy.ts +1 -1
- package/src/coverage.ts +49 -0
- package/src/emulator-freshness-cli.ts +49 -0
- package/src/emulator-freshness.test.ts +86 -0
- package/src/emulator-freshness.ts +87 -0
- package/src/index.ts +15 -0
- package/src/mcp/context-tools.test.ts +27 -0
- package/src/mcp/context-tools.ts +120 -0
- package/src/op/activities/emulator-images.ts +21 -0
- package/src/op/activities/flaps.test.ts +2 -1
- package/src/op/activities/flaps.ts +3 -2
- package/src/op/activities/index.ts +53 -0
- package/src/op/activities/machines-contract.docker.integration.test.ts +72 -0
- package/src/op/activities/machines-contract.test.ts +49 -0
- package/src/op/activities/machines-contract.ts +73 -0
- package/src/op/activities/sprites-contract.docker.integration.test.ts +74 -0
- package/src/op/activities/sprites-contract.test.ts +60 -0
- package/src/op/activities/sprites-contract.ts +61 -0
- package/src/op/activities/sprites-emulator.ts +46 -0
- package/src/op/activities/sprites-fake.ts +314 -0
- package/src/op/activities/sprites.docker.integration.test.ts +99 -0
- package/src/op/activities/sprites.integration.test.ts +158 -0
- package/src/op/activities/sprites.real.test.ts +56 -0
- package/src/op/activities/sprites.test.ts +296 -0
- package/src/op/activities/sprites.ts +527 -0
- package/src/plugin.ts +28 -5
- package/src/reference-catalog.test.ts +50 -0
- package/src/reference-catalog.ts +39 -0
- package/src/skills/chant-fly-patterns.md +2 -2
- package/src/skills/chant-fly-sprites.md +104 -0
- package/src/skills/chant-fly.md +2 -2
- package/src/generated/.gitkeep +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprites lifecycle activities (#762, #766) — imperative, checkpointable sandbox
|
|
3
|
+
* primitives ([sprites.dev](https://sprites.dev)) as chant Op activities, wired
|
|
4
|
+
* to the faithful Sprites API.
|
|
5
|
+
*
|
|
6
|
+
* Unlike a resource lexicon, Sprites have no desired state to reconcile: they
|
|
7
|
+
* are runtime-orchestration primitives (the same category as `k3dUp` /
|
|
8
|
+
* `httpCheck`). Most activities are a direct call over an injectable HTTP client
|
|
9
|
+
* (`SpritesHttp`); `spriteExec` is the exception — it speaks the control
|
|
10
|
+
* WebSocket exec protocol (non-PTY stream framing, per superfly/sprites-go), so
|
|
11
|
+
* it opens a `ws` connection instead. Exported pure helpers (endpoint
|
|
12
|
+
* resolution, the exec frame accumulator, NDJSON parsers, the comment picker)
|
|
13
|
+
* keep the logic unit-testable without a socket or an HTTP server.
|
|
14
|
+
*
|
|
15
|
+
* The headline capability is checkpoint-as-compensation (S5): an Op checkpoints
|
|
16
|
+
* before a risky phase and, on failure, `spriteRestore`s the labeled checkpoint
|
|
17
|
+
* instead of unwinding with an inverse action — the environment itself is the
|
|
18
|
+
* transaction.
|
|
19
|
+
*
|
|
20
|
+
* S3: endpoint override via `SPRITES_BASE_URL` (an explicit `endpoint` arg wins,
|
|
21
|
+
* then the env, then the real Sprites base), so the same Op targets real Sprites
|
|
22
|
+
* or the in-process fake with no code change.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import WebSocket from "ws";
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_SPRITES_BASE_URL = "https://api.sprites.dev";
|
|
28
|
+
|
|
29
|
+
// ── Pure helpers (unit-testable without http/ws) ──────────────────────────────
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the Sprites base URL (S3): an explicit `endpoint` arg wins, then the
|
|
33
|
+
* `SPRITES_BASE_URL` env, then the real-Sprites default. The trailing slash is
|
|
34
|
+
* stripped so `${base}/v1/...` never doubles up. Pure — mirrors fly's
|
|
35
|
+
* `resolveEndpoint`.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveSpritesEndpoint(
|
|
38
|
+
args: { endpoint?: string } = {},
|
|
39
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
40
|
+
): string {
|
|
41
|
+
const base = args.endpoint || env.SPRITES_BASE_URL || DEFAULT_SPRITES_BASE_URL;
|
|
42
|
+
return base.replace(/\/$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const spritesUrl = (base: string): string => `${base}/v1/sprites`;
|
|
46
|
+
const spriteUrl = (base: string, id: string): string => `${spritesUrl(base)}/${encodeURIComponent(id)}`;
|
|
47
|
+
// Create uses REST JSON; exec is the control WebSocket below; checkpoints are NDJSON.
|
|
48
|
+
const spriteCheckpointUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoint`;
|
|
49
|
+
const spriteCheckpointsUrl = (base: string, id: string): string => `${spriteUrl(base, id)}/checkpoints`;
|
|
50
|
+
const spriteCheckpointRestoreUrl = (base: string, id: string, cp: string): string =>
|
|
51
|
+
`${spriteCheckpointsUrl(base, id)}/${encodeURIComponent(cp)}/restore`;
|
|
52
|
+
|
|
53
|
+
function safeJson(text: string): unknown {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(text);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Exec stream framing (non-PTY control WebSocket) ────────────────────────────
|
|
62
|
+
//
|
|
63
|
+
// Every WebSocket message is a binary frame `[StreamID:1 byte][payload]`. The
|
|
64
|
+
// client with no stdin sends a single `[4]` (StreamStdinEOF); the server writes
|
|
65
|
+
// stdout as `[1]<bytes>`, stderr as `[2]<bytes>`, then `[3]<exitcodebyte>` and
|
|
66
|
+
// closes. See superfly/sprites-go websocket.go/exec.go.
|
|
67
|
+
export const STREAM_STDIN = 0;
|
|
68
|
+
export const STREAM_STDOUT = 1;
|
|
69
|
+
export const STREAM_STDERR = 2;
|
|
70
|
+
export const STREAM_EXIT = 3;
|
|
71
|
+
export const STREAM_STDIN_EOF = 4;
|
|
72
|
+
|
|
73
|
+
function toBytes(data: unknown): Uint8Array {
|
|
74
|
+
if (data instanceof Uint8Array) return data;
|
|
75
|
+
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
76
|
+
if (Array.isArray(data)) return Buffer.concat(data.map((d) => toBytes(d) as Buffer));
|
|
77
|
+
if (typeof data === "string") return new TextEncoder().encode(data);
|
|
78
|
+
return new Uint8Array(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Accumulate a stream of exec frames into `{ stdout, stderr, exitCode }`. Pure
|
|
83
|
+
* and socket-free so the framing is unit-testable: feed `[1]"hi\n"`, `[3]\x00`
|
|
84
|
+
* and get `{ stdout: "hi\n", exitCode: 0 }`. Per-stream payloads are collected
|
|
85
|
+
* as bytes and decoded once, so a multi-byte character split across frames is
|
|
86
|
+
* preserved. The exit code is the first byte of the `[3]` frame (0 when absent).
|
|
87
|
+
*/
|
|
88
|
+
export function accumulateExecFrames(frames: Iterable<Uint8Array>): SpriteExecResult {
|
|
89
|
+
const outChunks: Uint8Array[] = [];
|
|
90
|
+
const errChunks: Uint8Array[] = [];
|
|
91
|
+
let exitCode = 0;
|
|
92
|
+
for (const frame of frames) {
|
|
93
|
+
if (!frame || frame.length === 0) continue;
|
|
94
|
+
const stream = frame[0];
|
|
95
|
+
const payload = frame.subarray(1);
|
|
96
|
+
if (stream === STREAM_STDOUT) outChunks.push(payload);
|
|
97
|
+
else if (stream === STREAM_STDERR) errChunks.push(payload);
|
|
98
|
+
else if (stream === STREAM_EXIT) exitCode = payload.length > 0 ? payload[0] : 0;
|
|
99
|
+
}
|
|
100
|
+
const dec = new TextDecoder();
|
|
101
|
+
return {
|
|
102
|
+
stdout: dec.decode(concat(outChunks)),
|
|
103
|
+
stderr: dec.decode(concat(errChunks)),
|
|
104
|
+
exitCode,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function concat(chunks: Uint8Array[]): Uint8Array {
|
|
109
|
+
if (chunks.length === 0) return new Uint8Array(0);
|
|
110
|
+
if (chunks.length === 1) return chunks[0];
|
|
111
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Tokenize a command string into an argv, respecting single/double quotes so
|
|
116
|
+
* `sh -c "exit 7"` becomes `["sh", "-c", "exit 7"]`. Each element is sent as a
|
|
117
|
+
* `cmd` query param; `path` is `argv[0]`. Pure.
|
|
118
|
+
*/
|
|
119
|
+
export function splitCommand(cmd: string): string[] {
|
|
120
|
+
const argv: string[] = [];
|
|
121
|
+
let cur = "";
|
|
122
|
+
let has = false;
|
|
123
|
+
let inSingle = false;
|
|
124
|
+
let inDouble = false;
|
|
125
|
+
for (const ch of cmd) {
|
|
126
|
+
if (inSingle) {
|
|
127
|
+
if (ch === "'") inSingle = false;
|
|
128
|
+
else cur += ch;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (inDouble) {
|
|
132
|
+
if (ch === '"') inDouble = false;
|
|
133
|
+
else cur += ch;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (ch === "'") {
|
|
137
|
+
inSingle = true;
|
|
138
|
+
has = true;
|
|
139
|
+
} else if (ch === '"') {
|
|
140
|
+
inDouble = true;
|
|
141
|
+
has = true;
|
|
142
|
+
} else if (ch === " " || ch === "\t" || ch === "\n") {
|
|
143
|
+
if (has) {
|
|
144
|
+
argv.push(cur);
|
|
145
|
+
cur = "";
|
|
146
|
+
has = false;
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
cur += ch;
|
|
150
|
+
has = true;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (has) argv.push(cur);
|
|
154
|
+
return argv;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Build the `wss://.../exec?cmd=...&path=...&stdin=false&cc=true` URL for a
|
|
159
|
+
* command (http→ws, https→wss). Pure.
|
|
160
|
+
*/
|
|
161
|
+
export function spriteExecWsUrl(base: string, id: string, cmd: string): string {
|
|
162
|
+
const wsBase = base.replace(/^http(s?):\/\//i, (_m, s: string) => `ws${s}://`);
|
|
163
|
+
const argv = splitCommand(cmd);
|
|
164
|
+
const params = new URLSearchParams();
|
|
165
|
+
for (const a of argv) params.append("cmd", a);
|
|
166
|
+
params.set("path", argv[0] ?? "");
|
|
167
|
+
params.set("stdin", "false");
|
|
168
|
+
params.set("cc", "true");
|
|
169
|
+
return `${wsBase}/v1/sprites/${encodeURIComponent(id)}/exec?${params.toString()}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Checkpoint NDJSON + comment picker ─────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Parse a checkpoint create NDJSON body (line-delimited JSON progress events)
|
|
176
|
+
* and capture the created version id. Real Sprites tags each event with a
|
|
177
|
+
* `type` and a human `data` string — the version id rides inside the text
|
|
178
|
+
* (" ID: v1" and "Checkpoint v1 created successfully"), not a structured
|
|
179
|
+
* field. The in-process fake mirrors that. An older shape (`{event, id}`) is
|
|
180
|
+
* still honored so a structured id always wins. Pure; blank/unparseable lines
|
|
181
|
+
* are skipped; `checkpointId` is "" when the stream carries no id.
|
|
182
|
+
*/
|
|
183
|
+
export function parseCheckpointNdjson(text: string): SpriteCheckpointResult {
|
|
184
|
+
let structuredId = "";
|
|
185
|
+
let textId = "";
|
|
186
|
+
for (const line of text.split("\n")) {
|
|
187
|
+
const t = line.trim();
|
|
188
|
+
if (!t) continue;
|
|
189
|
+
const obj = safeJson(t) as
|
|
190
|
+
| { event?: string; type?: string; id?: string; data?: string }
|
|
191
|
+
| undefined;
|
|
192
|
+
if (!obj) continue;
|
|
193
|
+
const kind = obj.event ?? obj.type;
|
|
194
|
+
// Structured id (older emulator shape) — always preferred when present.
|
|
195
|
+
if (kind === "complete" && typeof obj.id === "string" && obj.id) structuredId = obj.id;
|
|
196
|
+
// Otherwise mine the version id out of the message text. The " ID: v1"
|
|
197
|
+
// detail line and the "Checkpoint v1 created successfully" completion line
|
|
198
|
+
// both carry it; either is enough.
|
|
199
|
+
if (typeof obj.data === "string") {
|
|
200
|
+
const fromId = obj.data.match(/(?:^|\s)ID:\s*(\S+)/);
|
|
201
|
+
if (fromId) textId = fromId[1];
|
|
202
|
+
const fromComplete = obj.data.match(/Checkpoint\s+(\S+)\s+created/i);
|
|
203
|
+
if (fromComplete) textId = fromComplete[1];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { checkpointId: structuredId || textId };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Pick the newest checkpoint whose `comment` matches, so a comment-tagged
|
|
211
|
+
* restore rewinds to the most recent labeled snapshot. Newest is by
|
|
212
|
+
* `create_time`; array order breaks ties (the list is chronological). Pure.
|
|
213
|
+
*/
|
|
214
|
+
export function pickCheckpointByComment(list: Checkpoint[], comment: string): Checkpoint | undefined {
|
|
215
|
+
return newestOf(list.filter((c) => c.comment === comment));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function newestOf(list: Checkpoint[]): Checkpoint | undefined {
|
|
219
|
+
let best: Checkpoint | undefined;
|
|
220
|
+
for (const c of list) {
|
|
221
|
+
if (!best || compareCreateTime(c, best) >= 0) best = c;
|
|
222
|
+
}
|
|
223
|
+
return best;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function compareCreateTime(a: Checkpoint, b: Checkpoint): number {
|
|
227
|
+
const ta = Date.parse(a.create_time);
|
|
228
|
+
const tb = Date.parse(b.create_time);
|
|
229
|
+
if (Number.isNaN(ta) || Number.isNaN(tb)) return 0;
|
|
230
|
+
return ta - tb;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Activity contracts (the stable interface the Ops/emulator target) ──────────
|
|
234
|
+
|
|
235
|
+
export interface SpriteCreateArgs {
|
|
236
|
+
/** Caller-chosen name, used as the sprite `id` (S4). Every later activity keys on it. */
|
|
237
|
+
name: string;
|
|
238
|
+
/** Base image for the sandbox. */
|
|
239
|
+
image?: string;
|
|
240
|
+
/** Sandbox size (vCPU/memory class). */
|
|
241
|
+
size?: string;
|
|
242
|
+
/** Network / execution policy passed through to the sprite. */
|
|
243
|
+
policy?: unknown;
|
|
244
|
+
/** Endpoint override (S3). Default: `SPRITES_BASE_URL`, else real Sprites. */
|
|
245
|
+
endpoint?: string;
|
|
246
|
+
/** Bearer token. Default: `SPRITES_API_TOKEN`. The fake ignores it. */
|
|
247
|
+
token?: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface SpriteCreateResult {
|
|
251
|
+
id: string;
|
|
252
|
+
url: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface SpriteExecArgs {
|
|
256
|
+
/** Target sprite id (the `name` passed to `spriteCreate`). */
|
|
257
|
+
id: string;
|
|
258
|
+
/** Command to run inside the sprite (tokenized into argv, quotes respected). */
|
|
259
|
+
cmd: string;
|
|
260
|
+
/** Per-exec timeout in ms. */
|
|
261
|
+
timeoutMs?: number;
|
|
262
|
+
endpoint?: string;
|
|
263
|
+
token?: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export interface SpriteExecResult {
|
|
267
|
+
stdout: string;
|
|
268
|
+
stderr: string;
|
|
269
|
+
exitCode: number;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface SpriteCheckpointArgs {
|
|
273
|
+
id: string;
|
|
274
|
+
/** Caller-chosen checkpoint comment (S4); a comment-tagged `spriteRestore` matches it. */
|
|
275
|
+
comment?: string;
|
|
276
|
+
endpoint?: string;
|
|
277
|
+
token?: string;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface SpriteCheckpointResult {
|
|
281
|
+
/** The server checkpoint version (e.g. `v3`) captured from the `complete` event. */
|
|
282
|
+
checkpointId: string;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface Checkpoint {
|
|
286
|
+
id: string;
|
|
287
|
+
comment: string;
|
|
288
|
+
create_time: string;
|
|
289
|
+
is_auto: boolean;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface SpriteRestoreArgs {
|
|
293
|
+
id: string;
|
|
294
|
+
/** Explicit checkpoint id (e.g. `v3`); wins over `comment`. */
|
|
295
|
+
checkpoint?: string;
|
|
296
|
+
/** Restore the newest checkpoint carrying this comment. */
|
|
297
|
+
comment?: string;
|
|
298
|
+
endpoint?: string;
|
|
299
|
+
token?: string;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface ListCheckpointsArgs {
|
|
303
|
+
id: string;
|
|
304
|
+
endpoint?: string;
|
|
305
|
+
token?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface SpriteDestroyArgs {
|
|
309
|
+
id: string;
|
|
310
|
+
endpoint?: string;
|
|
311
|
+
token?: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Build the `POST /v1/sprites` body. Pure. */
|
|
315
|
+
export function spriteCreateBody(args: SpriteCreateArgs): Record<string, unknown> {
|
|
316
|
+
return {
|
|
317
|
+
name: args.name,
|
|
318
|
+
...(args.image !== undefined ? { image: args.image } : {}),
|
|
319
|
+
...(args.size !== undefined ? { size: args.size } : {}),
|
|
320
|
+
...(args.policy !== undefined ? { policy: args.policy } : {}),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Parse the create response; the caller-chosen `name` is the id fallback (S4). Pure. */
|
|
325
|
+
export function parseCreateResponse(text: string, name: string): SpriteCreateResult {
|
|
326
|
+
const b = safeJson(text) as { id?: string; url?: string } | undefined;
|
|
327
|
+
return { id: b?.id ?? name, url: b?.url ?? "" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── HTTP ───────────────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Injectable HTTP client — mirrors fly's `FlyHttp`. Tests inject a fake; the
|
|
334
|
+
* default hits `fetch`. Used by create/destroy/checkpoint/list/restore; exec
|
|
335
|
+
* goes over the control WebSocket instead.
|
|
336
|
+
*/
|
|
337
|
+
export type SpritesHttp = (
|
|
338
|
+
method: string,
|
|
339
|
+
url: string,
|
|
340
|
+
body?: unknown,
|
|
341
|
+
headers?: Record<string, string>,
|
|
342
|
+
signal?: AbortSignal,
|
|
343
|
+
) => Promise<{ status: number; text: string }>;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Default `fetch`-based client. Sends `Authorization: Bearer <token>` when a
|
|
347
|
+
* token is set (real Sprites); the fake ignores it. The token defaults to
|
|
348
|
+
* `SPRITES_API_TOKEN` at call time. `fetchImpl` is injectable for tests.
|
|
349
|
+
*/
|
|
350
|
+
export function defaultSpritesHttp(token?: string, fetchImpl: typeof fetch = fetch): SpritesHttp {
|
|
351
|
+
return async (method, url, body, headers, signal) => {
|
|
352
|
+
const h: Record<string, string> = { ...headers };
|
|
353
|
+
if (body !== undefined) h["content-type"] = "application/json";
|
|
354
|
+
const tok = token ?? process.env.SPRITES_API_TOKEN;
|
|
355
|
+
if (tok) h["authorization"] = `Bearer ${tok}`;
|
|
356
|
+
const res = await fetchImpl(url, {
|
|
357
|
+
method,
|
|
358
|
+
headers: Object.keys(h).length ? h : undefined,
|
|
359
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
360
|
+
signal,
|
|
361
|
+
});
|
|
362
|
+
return { status: res.status, text: await res.text() };
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ── Activities (ActivityFn: (args, signal?) => Promise<unknown>) ──────────────
|
|
367
|
+
|
|
368
|
+
/** Create a sprite with the caller-chosen `name` as its id (S4). `POST /v1/sprites`. */
|
|
369
|
+
export async function spriteCreate(
|
|
370
|
+
args: SpriteCreateArgs,
|
|
371
|
+
signal?: AbortSignal,
|
|
372
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
373
|
+
): Promise<SpriteCreateResult> {
|
|
374
|
+
const base = resolveSpritesEndpoint(args);
|
|
375
|
+
const res = await http("POST", spritesUrl(base), spriteCreateBody(args), undefined, signal);
|
|
376
|
+
if (res.status >= 300) throw new Error(`sprite ${args.name} create failed (${res.status}): ${res.text}`);
|
|
377
|
+
const result = parseCreateResponse(res.text, args.name);
|
|
378
|
+
console.log(`created: sprite/${result.id} (${base})`);
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Run a command in the sprite over the control WebSocket (non-PTY stream
|
|
384
|
+
* framing, per superfly/sprites-go). Connects to `wss://.../exec`, sends a
|
|
385
|
+
* single `[4]` (stdin EOF), accumulates stdout/stderr frames, and reads the
|
|
386
|
+
* exit code from the `[3]` frame. A non-zero exit is a failed activity (it
|
|
387
|
+
* throws) so a risky step fails its phase and triggers `onFailure`
|
|
388
|
+
* compensation (S5).
|
|
389
|
+
*/
|
|
390
|
+
export async function spriteExec(args: SpriteExecArgs, signal?: AbortSignal): Promise<SpriteExecResult> {
|
|
391
|
+
const base = resolveSpritesEndpoint(args);
|
|
392
|
+
const url = spriteExecWsUrl(base, args.id, args.cmd);
|
|
393
|
+
const token = args.token ?? process.env.SPRITES_API_TOKEN;
|
|
394
|
+
const headers: Record<string, string> = {};
|
|
395
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
396
|
+
|
|
397
|
+
const result = await new Promise<SpriteExecResult>((resolve, reject) => {
|
|
398
|
+
const frames: Uint8Array[] = [];
|
|
399
|
+
const ws = new WebSocket(url, { headers });
|
|
400
|
+
let settled = false;
|
|
401
|
+
const finish = (fn: () => void): void => {
|
|
402
|
+
if (settled) return;
|
|
403
|
+
settled = true;
|
|
404
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
405
|
+
fn();
|
|
406
|
+
};
|
|
407
|
+
const onAbort = (): void => {
|
|
408
|
+
try {
|
|
409
|
+
ws.terminate();
|
|
410
|
+
} catch {
|
|
411
|
+
/* already closed */
|
|
412
|
+
}
|
|
413
|
+
finish(() => reject(new Error(`sprite ${args.id} exec aborted`)));
|
|
414
|
+
};
|
|
415
|
+
if (signal) {
|
|
416
|
+
if (signal.aborted) return onAbort();
|
|
417
|
+
signal.addEventListener("abort", onAbort);
|
|
418
|
+
}
|
|
419
|
+
ws.on("open", () => {
|
|
420
|
+
// No stdin: signal EOF immediately (belt and braces with stdin=false).
|
|
421
|
+
ws.send(Uint8Array.of(STREAM_STDIN_EOF));
|
|
422
|
+
});
|
|
423
|
+
ws.on("message", (data) => {
|
|
424
|
+
const bytes = toBytes(data);
|
|
425
|
+
frames.push(bytes);
|
|
426
|
+
if (bytes.length > 0 && bytes[0] === STREAM_EXIT) {
|
|
427
|
+
try {
|
|
428
|
+
ws.close();
|
|
429
|
+
} catch {
|
|
430
|
+
/* closing already */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
ws.on("error", (err) => finish(() => reject(err)));
|
|
435
|
+
ws.on("close", () => finish(() => resolve(accumulateExecFrames(frames))));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
if (result.exitCode !== 0) {
|
|
439
|
+
throw new Error(
|
|
440
|
+
`sprite ${args.id} exec "${args.cmd}" exited ${result.exitCode}: ${result.stderr || result.stdout}`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
return result;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Checkpoint the sprite. `POST /v1/sprites/{id}/checkpoint` (singular); the
|
|
448
|
+
* `comment` key is omitted when empty. The response is an NDJSON progress
|
|
449
|
+
* stream; the created version id is mined from the message text (see
|
|
450
|
+
* `parseCheckpointNdjson`).
|
|
451
|
+
*/
|
|
452
|
+
export async function spriteCheckpoint(
|
|
453
|
+
args: SpriteCheckpointArgs,
|
|
454
|
+
signal?: AbortSignal,
|
|
455
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
456
|
+
): Promise<SpriteCheckpointResult> {
|
|
457
|
+
const base = resolveSpritesEndpoint(args);
|
|
458
|
+
const body = args.comment ? { comment: args.comment } : undefined;
|
|
459
|
+
const res = await http("POST", spriteCheckpointUrl(base, args.id), body, undefined, signal);
|
|
460
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} checkpoint failed (${res.status}): ${res.text}`);
|
|
461
|
+
const result = parseCheckpointNdjson(res.text);
|
|
462
|
+
console.log(`checkpoint: sprite/${args.id} @${result.checkpointId} (${base})`);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* List a sprite's checkpoints. `GET /v1/sprites/{id}/checkpoints` → a bare array
|
|
468
|
+
* `[{ id, comment, create_time, is_auto }]` (auto checkpoints excluded by
|
|
469
|
+
* default on the server).
|
|
470
|
+
*/
|
|
471
|
+
export async function listCheckpoints(
|
|
472
|
+
args: ListCheckpointsArgs,
|
|
473
|
+
signal?: AbortSignal,
|
|
474
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
475
|
+
): Promise<Checkpoint[]> {
|
|
476
|
+
const base = resolveSpritesEndpoint(args);
|
|
477
|
+
const res = await http("GET", spriteCheckpointsUrl(base, args.id), undefined, undefined, signal);
|
|
478
|
+
if (res.status >= 300) throw new Error(`sprite ${args.id} list checkpoints failed (${res.status}): ${res.text}`);
|
|
479
|
+
const parsed = safeJson(res.text);
|
|
480
|
+
return Array.isArray(parsed) ? (parsed as Checkpoint[]) : [];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Restore the sprite to a checkpoint (S5, the compensation path). Resolution
|
|
485
|
+
* order: an explicit `checkpoint` id wins; otherwise the newest checkpoint
|
|
486
|
+
* carrying `comment`; otherwise the newest checkpoint overall. Restore is
|
|
487
|
+
* `POST /v1/sprites/{id}/checkpoints/{cp}/restore` and returns an NDJSON stream.
|
|
488
|
+
*/
|
|
489
|
+
export async function spriteRestore(
|
|
490
|
+
args: SpriteRestoreArgs,
|
|
491
|
+
signal?: AbortSignal,
|
|
492
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
493
|
+
): Promise<Record<string, never>> {
|
|
494
|
+
const base = resolveSpritesEndpoint(args);
|
|
495
|
+
let target = args.checkpoint;
|
|
496
|
+
if (!target) {
|
|
497
|
+
const list = await listCheckpoints({ ...args }, signal, http);
|
|
498
|
+
const picked =
|
|
499
|
+
args.comment !== undefined ? pickCheckpointByComment(list, args.comment) : newestOf(list);
|
|
500
|
+
if (!picked) {
|
|
501
|
+
const which = args.comment !== undefined ? `comment "${args.comment}"` : "any checkpoint";
|
|
502
|
+
throw new Error(`sprite ${args.id} restore: no checkpoint matching ${which}`);
|
|
503
|
+
}
|
|
504
|
+
target = picked.id;
|
|
505
|
+
}
|
|
506
|
+
const res = await http("POST", spriteCheckpointRestoreUrl(base, args.id, target), undefined, undefined, signal);
|
|
507
|
+
if (res.status >= 300) {
|
|
508
|
+
throw new Error(`sprite ${args.id} restore to "${target}" failed (${res.status}): ${res.text}`);
|
|
509
|
+
}
|
|
510
|
+
console.log(`restored: sprite/${args.id} to ${target} (${base})`);
|
|
511
|
+
return {};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Destroy the sprite (idempotent; a 404 means it is already gone). `DELETE /v1/sprites/{id}`. */
|
|
515
|
+
export async function spriteDestroy(
|
|
516
|
+
args: SpriteDestroyArgs,
|
|
517
|
+
signal?: AbortSignal,
|
|
518
|
+
http: SpritesHttp = defaultSpritesHttp(args.token),
|
|
519
|
+
): Promise<Record<string, never>> {
|
|
520
|
+
const base = resolveSpritesEndpoint(args);
|
|
521
|
+
const res = await http("DELETE", spriteUrl(base, args.id), undefined, undefined, signal);
|
|
522
|
+
if (res.status >= 300 && res.status !== 404) {
|
|
523
|
+
throw new Error(`sprite ${args.id} destroy failed (${res.status}): ${res.text}`);
|
|
524
|
+
}
|
|
525
|
+
console.log(`destroyed: sprite/${args.id} (${base})`);
|
|
526
|
+
return {};
|
|
527
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { hover } from "./lsp/hover";
|
|
|
14
14
|
import { detectTemplate as detectFlyTemplate } from "./detect";
|
|
15
15
|
import { FlyParser } from "./import/parser";
|
|
16
16
|
import { FlyGenerator } from "./import/generator";
|
|
17
|
+
import { flyReferenceCatalog } from "./reference-catalog";
|
|
18
|
+
import { flyContextTools } from "./mcp/context-tools";
|
|
17
19
|
|
|
18
20
|
/**
|
|
19
21
|
* fly lexicon plugin.
|
|
@@ -39,8 +41,8 @@ export const flyPlugin: LexiconPlugin = {
|
|
|
39
41
|
},
|
|
40
42
|
|
|
41
43
|
async coverage(options?: { verbose?: boolean; minOverall?: number }): Promise<void> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
+
const { analyzeFlyCoverage } = await import("./coverage");
|
|
45
|
+
await analyzeFlyCoverage({ verbose: options?.verbose, minOverall: options?.minOverall });
|
|
44
46
|
},
|
|
45
47
|
|
|
46
48
|
async package(options?: { verbose?: boolean; force?: boolean }): Promise<void> {
|
|
@@ -86,7 +88,7 @@ export const flyPlugin: LexiconPlugin = {
|
|
|
86
88
|
examples: [
|
|
87
89
|
{
|
|
88
90
|
title: "Author an App and a Machine",
|
|
89
|
-
output: "new App({ name: \"my-app\" });\nnew Machine({ region: \"iad\", config: new MachineConfig({ image: \"flyio/hellofly:latest\" }) })",
|
|
91
|
+
output: "new App({ name: \"my-app\", org_slug: Fly.OrgSlug });\nnew Machine({ region: \"iad\", config: new MachineConfig({ image: \"flyio/hellofly:latest\" }) })",
|
|
90
92
|
},
|
|
91
93
|
{
|
|
92
94
|
title: "Deploy against mudflaps offline",
|
|
@@ -131,14 +133,30 @@ export const flyPlugin: LexiconPlugin = {
|
|
|
131
133
|
},
|
|
132
134
|
],
|
|
133
135
|
},
|
|
136
|
+
{
|
|
137
|
+
file: "chant-fly-sprites.md",
|
|
138
|
+
name: "chant-fly-sprites",
|
|
139
|
+
description: "Run an agent task in a Sprite as a chant Op — create, exec, checkpoint, restore, destroy, with checkpoint-as-compensation",
|
|
140
|
+
triggers: [
|
|
141
|
+
{ type: "context" as const, value: "sprite" },
|
|
142
|
+
{ type: "context" as const, value: "sprites.dev" },
|
|
143
|
+
{ type: "context" as const, value: "agent sandbox" },
|
|
144
|
+
{ type: "context" as const, value: "checkpoint compensation" },
|
|
145
|
+
],
|
|
146
|
+
parameters: [],
|
|
147
|
+
examples: [],
|
|
148
|
+
},
|
|
134
149
|
]),
|
|
135
150
|
|
|
136
151
|
mcpTools() {
|
|
137
|
-
return
|
|
152
|
+
return flyContextTools();
|
|
138
153
|
},
|
|
139
154
|
|
|
140
155
|
mcpResources() {
|
|
141
|
-
|
|
156
|
+
// No standing MCP resources: Fly's read-only context is the fly:* tools above
|
|
157
|
+
// (build-from-source) plus the chant-fly skills. There's no static catalog or
|
|
158
|
+
// per-project artifact to expose as a resource.
|
|
159
|
+
return [];
|
|
142
160
|
},
|
|
143
161
|
|
|
144
162
|
detectTemplate(data: unknown) {
|
|
@@ -282,4 +300,9 @@ export { app, web };
|
|
|
282
300
|
const { exportResources } = await import("./export-resources");
|
|
283
301
|
return exportResources(options);
|
|
284
302
|
},
|
|
303
|
+
|
|
304
|
+
// Live graph edges (#804): how observed Fly resources reference each other, so
|
|
305
|
+
// `chant graph --live` reconstructs the topology (App boundary + mount edges).
|
|
306
|
+
// No `enrichLiveAttrs` needed — describeResources already returns rich attrs.
|
|
307
|
+
referenceCatalog: flyReferenceCatalog,
|
|
285
308
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { reconstructEdges, containmentGroups } from "@intentius/chant/graph-refs";
|
|
3
|
+
import type { IRNode } from "@intentius/chant/graph-ir";
|
|
4
|
+
import { flyReferenceCatalog } from "./reference-catalog";
|
|
5
|
+
|
|
6
|
+
const n = (id: string, kind: string, physicalId: string, attrs: Record<string, unknown>): IRNode => ({
|
|
7
|
+
id,
|
|
8
|
+
kind,
|
|
9
|
+
lexicon: "fly",
|
|
10
|
+
physicalId,
|
|
11
|
+
attrs,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// The shape describeResources() returns: an App boundary, machines (one mounting
|
|
15
|
+
// a volume), a volume, an IP — all app-scoped.
|
|
16
|
+
const nodes: IRNode[] = [
|
|
17
|
+
n("app", "Fly::Machines::App", "my-app", { app: "my-app" }),
|
|
18
|
+
n("web", "Fly::Machines::Machine", "m1", { app: "my-app", machineName: "web", config: { image: "flyio/hellofly:latest", mounts: [{ volume: "vol_1", path: "/data" }] } }),
|
|
19
|
+
n("worker", "Fly::Machines::Machine", "m2", { app: "my-app", machineName: "worker", config: { image: "flyio/hellofly:latest" } }),
|
|
20
|
+
n("data", "Fly::Machines::Volume", "vol_1", { app: "my-app", volumeName: "data" }),
|
|
21
|
+
n("ip", "Fly::Machines::IPAddress", "1.2.3.4", { app: "my-app", family: "v4" }),
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
describe("flyReferenceCatalog — live graph edges", () => {
|
|
25
|
+
const { edges, containment, dangling } = reconstructEdges(nodes, flyReferenceCatalog);
|
|
26
|
+
|
|
27
|
+
it("contains every app-scoped resource in its App (boundary box)", () => {
|
|
28
|
+
for (const child of ["web", "worker", "data", "ip"]) {
|
|
29
|
+
expect(containment).toContainEqual({ child, parent: "app", label: "in app" });
|
|
30
|
+
}
|
|
31
|
+
// containment is never an edge
|
|
32
|
+
expect(edges.some((e) => e.to === "app")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("reconstructs the machine → volume mount edge", () => {
|
|
36
|
+
// mount.volume "vol_1" matches the Volume's physicalId
|
|
37
|
+
expect(edges).toContainEqual({ from: "web", to: "data", kind: "ref", viaAttr: "mounts" });
|
|
38
|
+
// the machine with no mounts has no volume edge
|
|
39
|
+
expect(edges.some((e) => e.from === "worker" && e.to === "data")).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("has no dangling references", () => {
|
|
43
|
+
expect(dangling).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("containment groups nest App → resources (for #779 boundaries)", () => {
|
|
47
|
+
const g = containmentGroups(containment);
|
|
48
|
+
expect(g.app).toEqual(expect.arrayContaining(["web", "worker", "data", "ip"]));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ReferenceCatalog } from "@intentius/chant/lexicon";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fly reference catalog (#804) — how observed Fly resources reference each other,
|
|
5
|
+
* so `chant graph --live` reconstructs the topology and draws the App as a
|
|
6
|
+
* boundary box (the reference resolver, chant#778; containment, chant#779).
|
|
7
|
+
*
|
|
8
|
+
* Fly's relationships are app-centric and simple. `describeResources`
|
|
9
|
+
* (./describe-resources.ts) already returns the rich per-resource shape — each
|
|
10
|
+
* app-scoped resource carries its owning `app`, and a machine carries its full
|
|
11
|
+
* `config` (with `mounts`) — so, unlike AWS (chant#784), Fly needs **no**
|
|
12
|
+
* `enrichLiveAttrs`: the references are already in the observed attributes.
|
|
13
|
+
*
|
|
14
|
+
* Keyed to the `describeResources` attribute shape:
|
|
15
|
+
* - App → `{ app }` (physicalId = app name)
|
|
16
|
+
* - Machine → `{ app, config }` (config.mounts[].volume references a Volume)
|
|
17
|
+
* - Volume → `{ app, volumeName }`
|
|
18
|
+
* - IPAddress → `{ app, ... }`
|
|
19
|
+
* - Certificate→ `{ app, ... }`
|
|
20
|
+
*/
|
|
21
|
+
export const flyReferenceCatalog: ReferenceCatalog = {
|
|
22
|
+
identities: [
|
|
23
|
+
// The App is identified by its name (also its physicalId), which every
|
|
24
|
+
// app-scoped resource carries in `app`.
|
|
25
|
+
{ kind: "Fly::Machines::App", ids: ["app"] },
|
|
26
|
+
// A Volume is referenced by name; its id is already indexed via physicalId,
|
|
27
|
+
// so a mount that names either resolves.
|
|
28
|
+
{ kind: "Fly::Machines::Volume", ids: ["volumeName"] },
|
|
29
|
+
],
|
|
30
|
+
refs: [
|
|
31
|
+
// Containment: everything app-scoped lives inside its App → a boundary box.
|
|
32
|
+
{ from: "Fly::Machines::Machine", path: "app", targetKind: "Fly::Machines::App", relation: "containment", label: "in app" },
|
|
33
|
+
{ from: "Fly::Machines::Volume", path: "app", targetKind: "Fly::Machines::App", relation: "containment", label: "in app" },
|
|
34
|
+
{ from: "Fly::Machines::IPAddress", path: "app", targetKind: "Fly::Machines::App", relation: "containment", label: "in app" },
|
|
35
|
+
{ from: "Fly::Machines::Certificate", path: "app", targetKind: "Fly::Machines::App", relation: "containment", label: "in app" },
|
|
36
|
+
// Reference edge: a machine mount → the Volume it mounts.
|
|
37
|
+
{ from: "Fly::Machines::Machine", path: "config.mounts[].volume", targetKind: "Fly::Machines::Volume", relation: "reference", label: "mounts" },
|
|
38
|
+
],
|
|
39
|
+
};
|