@asteroidai/cli 1.9.2 → 1.9.11
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/asteroid.js +1140 -316
- package/dist/core.js +869 -59
- package/package.json +3 -2
- package/src/commands.ts +13 -0
- package/src/core.ts +31 -0
- package/src/main.ts +2 -2
- package/src/output.test.ts +19 -0
- package/src/output.ts +11 -9
- package/src/program.test.ts +19 -0
- package/src/program.ts +15 -1
- package/src/reference.ts +1 -0
- package/src/run.test.ts +55 -2
- package/src/run.ts +62 -21
- package/src/sync/activities.ts +80 -0
- package/src/sync/commands.ts +111 -0
- package/src/sync/execution-pull.ts +273 -0
- package/src/sync/node-files.ts +47 -0
- package/src/sync/paths.ts +45 -0
- package/src/sync/run.ts +102 -0
- package/src/sync/sync.test.ts +463 -0
- package/src/sync/types.ts +58 -0
- package/src/sync/workflow-pull.ts +182 -0
- package/src/sync/workflow-push.ts +155 -0
- package/src/types.ts +2 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { UsageError } from "../args";
|
|
4
|
+
import type { BuiltRequest } from "../request";
|
|
5
|
+
import { formatExecutionPull, pullExecution } from "./execution-pull";
|
|
6
|
+
import { sha256 } from "./paths";
|
|
7
|
+
import {
|
|
8
|
+
type FileTree,
|
|
9
|
+
type LocalFiles,
|
|
10
|
+
MANIFEST,
|
|
11
|
+
type Manifest,
|
|
12
|
+
type Sync,
|
|
13
|
+
} from "./types";
|
|
14
|
+
import { pullWorkflow } from "./workflow-pull";
|
|
15
|
+
import { pushWorkflow } from "./workflow-push";
|
|
16
|
+
|
|
17
|
+
const WF = "11111111-aaaa-bbbb-cccc-000000000000";
|
|
18
|
+
const HEAD = "22222222-aaaa-bbbb-cccc-000000000000";
|
|
19
|
+
|
|
20
|
+
function memoryFiles(initial: Record<string, string> = {}) {
|
|
21
|
+
const store = new Map(
|
|
22
|
+
Object.entries(initial).map(([path, text]) => [path, Buffer.from(text)]),
|
|
23
|
+
);
|
|
24
|
+
const files: LocalFiles = {
|
|
25
|
+
read: async (path) => store.get(path),
|
|
26
|
+
write: async (path, bytes) => void store.set(path, Buffer.from(bytes)),
|
|
27
|
+
remove: async (path) => void store.delete(path),
|
|
28
|
+
list: async (dir) =>
|
|
29
|
+
[...store.keys()]
|
|
30
|
+
.filter((path) => path.startsWith(`${dir}/`))
|
|
31
|
+
.map((path) => path.slice(dir.length + 1))
|
|
32
|
+
.sort(),
|
|
33
|
+
};
|
|
34
|
+
const text = (path: string) => store.get(path)?.toString("utf8");
|
|
35
|
+
return { files, store, text };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fakeRemote(
|
|
39
|
+
routes: Record<string, (request: BuiltRequest) => unknown>,
|
|
40
|
+
) {
|
|
41
|
+
const calls: BuiltRequest[] = [];
|
|
42
|
+
const downloads: string[] = [];
|
|
43
|
+
const remote = {
|
|
44
|
+
async json(request: BuiltRequest) {
|
|
45
|
+
calls.push(request);
|
|
46
|
+
const handler = routes[`${request.method} ${request.path}`];
|
|
47
|
+
if (!handler)
|
|
48
|
+
throw new Error(`unexpected ${request.method} ${request.path}`);
|
|
49
|
+
return handler(request);
|
|
50
|
+
},
|
|
51
|
+
async download(url: string) {
|
|
52
|
+
downloads.push(url);
|
|
53
|
+
return Buffer.from(`bytes of ${url}`);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return { remote, calls, downloads };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tree(overrides: Partial<FileTree> = {}): FileTree {
|
|
60
|
+
return {
|
|
61
|
+
rev: 7,
|
|
62
|
+
version: 3,
|
|
63
|
+
versionId: HEAD,
|
|
64
|
+
workflowName: "Fill Form",
|
|
65
|
+
files: [
|
|
66
|
+
{
|
|
67
|
+
path: "settings.yaml",
|
|
68
|
+
kind: "structural",
|
|
69
|
+
size: 14,
|
|
70
|
+
content: "start: Login\n",
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
path: "memory.md",
|
|
74
|
+
kind: "agent_file",
|
|
75
|
+
size: 5,
|
|
76
|
+
contentBase64: Buffer.from("notes").toString("base64"),
|
|
77
|
+
},
|
|
78
|
+
{ path: "big.pdf", kind: "agent_file", size: 9000000, fileId: "f-big" },
|
|
79
|
+
],
|
|
80
|
+
...overrides,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headFiles = `GET /agents/v2/workflows/${WF}/head/files`;
|
|
85
|
+
const signedBig = `GET /agents/v2/workflows/${WF}/versions/${HEAD}/files/f-big/signed-url`;
|
|
86
|
+
|
|
87
|
+
describe("pullWorkflow", () => {
|
|
88
|
+
function pulled(initial: Record<string, string> = {}) {
|
|
89
|
+
const local = memoryFiles(initial);
|
|
90
|
+
const { remote, calls, downloads } = fakeRemote({
|
|
91
|
+
[headFiles]: () => tree(),
|
|
92
|
+
[signedBig]: () => ({ url: "https://storage/big" }),
|
|
93
|
+
});
|
|
94
|
+
const sync: Sync = { remote, files: local.files };
|
|
95
|
+
return { sync, local, calls, downloads };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
it("writes the head under <workflow-name>/head with every file's bytes", async () => {
|
|
99
|
+
const { sync, local, downloads } = pulled();
|
|
100
|
+
|
|
101
|
+
const result = await pullWorkflow(sync, { workflowId: WF, root: "out" });
|
|
102
|
+
|
|
103
|
+
expect(result.dir).toBe("out/Fill_Form/head");
|
|
104
|
+
expect(local.text("out/Fill_Form/head/settings.yaml")).toBe(
|
|
105
|
+
"start: Login\n",
|
|
106
|
+
);
|
|
107
|
+
expect(local.text("out/Fill_Form/head/memory.md")).toBe("notes");
|
|
108
|
+
expect(local.text("out/Fill_Form/head/big.pdf")).toBe(
|
|
109
|
+
"bytes of https://storage/big",
|
|
110
|
+
);
|
|
111
|
+
expect(downloads).toEqual(["https://storage/big"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("asks for every file's contents", async () => {
|
|
115
|
+
const { sync, calls } = pulled();
|
|
116
|
+
|
|
117
|
+
await pullWorkflow(sync, { workflowId: WF, root: "" });
|
|
118
|
+
|
|
119
|
+
expect(calls[0].query.get("contents")).toBe("all");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("records the rev and each file's hash for a later push", async () => {
|
|
123
|
+
const { sync, local } = pulled();
|
|
124
|
+
|
|
125
|
+
await pullWorkflow(sync, { workflowId: WF, root: "" });
|
|
126
|
+
|
|
127
|
+
const manifest = JSON.parse(
|
|
128
|
+
local.text(`Fill_Form/head/${MANIFEST}`)!,
|
|
129
|
+
) as Manifest;
|
|
130
|
+
expect(manifest).toMatchObject({
|
|
131
|
+
workflowId: WF,
|
|
132
|
+
versionId: HEAD,
|
|
133
|
+
head: true,
|
|
134
|
+
rev: 7,
|
|
135
|
+
});
|
|
136
|
+
expect(manifest.files["memory.md"]).toBe(sha256(Buffer.from("notes")));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("removes local files the workflow no longer has", async () => {
|
|
140
|
+
const { sync, local } = pulled({
|
|
141
|
+
"Fill_Form/head/nodes/Old/instructions.md": "gone",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await pullWorkflow(sync, { workflowId: WF, root: "" });
|
|
145
|
+
|
|
146
|
+
expect(local.store.has("Fill_Form/head/nodes/Old/instructions.md")).toBe(
|
|
147
|
+
false,
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("skips files over the size limit instead of downloading them", async () => {
|
|
152
|
+
const { sync, local, downloads } = pulled();
|
|
153
|
+
|
|
154
|
+
const result = await pullWorkflow(sync, {
|
|
155
|
+
workflowId: WF,
|
|
156
|
+
root: "",
|
|
157
|
+
maxFileBytes: 1000,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(downloads).toEqual([]);
|
|
161
|
+
expect(local.store.has("Fill_Form/head/big.pdf")).toBe(false);
|
|
162
|
+
expect(result.skipped).toEqual([
|
|
163
|
+
"big.pdf (9000000 bytes, over the size limit)",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("writes a pinned version under .versions/", async () => {
|
|
168
|
+
const local = memoryFiles();
|
|
169
|
+
const { remote } = fakeRemote({
|
|
170
|
+
[`GET /agents/v2/workflows/${WF}/versions/${HEAD}/files`]: () =>
|
|
171
|
+
tree({ files: [] }),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const result = await pullWorkflow(
|
|
175
|
+
{ remote, files: local.files },
|
|
176
|
+
{ workflowId: WF, versionId: HEAD, root: "" },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result.dir).toBe("Fill_Form/.versions/v3_22222222");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("refuses an entry whose path leaves the folder", async () => {
|
|
183
|
+
const local = memoryFiles();
|
|
184
|
+
const { remote } = fakeRemote({
|
|
185
|
+
[headFiles]: () =>
|
|
186
|
+
tree({
|
|
187
|
+
files: [
|
|
188
|
+
{ path: "../escape", kind: "structural", size: 1, content: "x" },
|
|
189
|
+
],
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const result = await pullWorkflow(
|
|
194
|
+
{ remote, files: local.files },
|
|
195
|
+
{ workflowId: WF, root: "" },
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
expect(result.skipped).toEqual(["../escape (unsafe path)"]);
|
|
199
|
+
expect([...local.store.keys()]).toEqual([`Fill_Form/head/${MANIFEST}`]);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("pushWorkflow", () => {
|
|
204
|
+
const dir = "Fill_Form/head";
|
|
205
|
+
|
|
206
|
+
function pulledHead(edits: Record<string, string | null>) {
|
|
207
|
+
const base: Record<string, string> = {
|
|
208
|
+
"settings.yaml": "start: Login\n",
|
|
209
|
+
"memory.md": "notes",
|
|
210
|
+
"old.md": "old",
|
|
211
|
+
};
|
|
212
|
+
const manifest: Manifest = {
|
|
213
|
+
workflowId: WF,
|
|
214
|
+
versionId: HEAD,
|
|
215
|
+
head: true,
|
|
216
|
+
rev: 7,
|
|
217
|
+
files: Object.fromEntries(
|
|
218
|
+
Object.entries(base).map(([p, t]) => [p, sha256(Buffer.from(t))]),
|
|
219
|
+
),
|
|
220
|
+
};
|
|
221
|
+
const merged = { ...base, ...edits };
|
|
222
|
+
const initial: Record<string, string> = {
|
|
223
|
+
[`${dir}/${MANIFEST}`]: JSON.stringify(manifest),
|
|
224
|
+
};
|
|
225
|
+
for (const [path, text] of Object.entries(merged)) {
|
|
226
|
+
if (text !== null) initial[`${dir}/${path}`] = text;
|
|
227
|
+
}
|
|
228
|
+
return memoryFiles(initial);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
it("sends changed and new files as writes and removed files as deletes", async () => {
|
|
232
|
+
const local = pulledHead({
|
|
233
|
+
"memory.md": "more notes",
|
|
234
|
+
"new.md": "hi",
|
|
235
|
+
"old.md": null,
|
|
236
|
+
});
|
|
237
|
+
let patched: unknown;
|
|
238
|
+
const { remote } = fakeRemote({
|
|
239
|
+
[`PATCH /agents/v2/workflows/${WF}/head/files`]: (request) => {
|
|
240
|
+
patched = request.json;
|
|
241
|
+
return {};
|
|
242
|
+
},
|
|
243
|
+
[headFiles]: () => tree({ rev: 8, files: [] }),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const result = await pushWorkflow({ remote, files: local.files }, dir);
|
|
247
|
+
|
|
248
|
+
expect(patched).toEqual({
|
|
249
|
+
baseRev: 7,
|
|
250
|
+
writes: [
|
|
251
|
+
{
|
|
252
|
+
path: "memory.md",
|
|
253
|
+
contentBase64: Buffer.from("more notes").toString("base64"),
|
|
254
|
+
},
|
|
255
|
+
{ path: "new.md", contentBase64: Buffer.from("hi").toString("base64") },
|
|
256
|
+
],
|
|
257
|
+
deletes: ["old.md"],
|
|
258
|
+
});
|
|
259
|
+
expect(result).toMatchObject({
|
|
260
|
+
written: ["memory.md", "new.md"],
|
|
261
|
+
deleted: ["old.md"],
|
|
262
|
+
rev: 8,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("takes the server's rendering of structural files and the new rev", async () => {
|
|
267
|
+
const local = pulledHead({ "settings.yaml": "start: Login\n" });
|
|
268
|
+
const { remote } = fakeRemote({
|
|
269
|
+
[`PATCH /agents/v2/workflows/${WF}/head/files`]: () => ({}),
|
|
270
|
+
[headFiles]: () =>
|
|
271
|
+
tree({
|
|
272
|
+
rev: 8,
|
|
273
|
+
files: [
|
|
274
|
+
{
|
|
275
|
+
path: "settings.yaml",
|
|
276
|
+
kind: "structural",
|
|
277
|
+
size: 13,
|
|
278
|
+
content: "start: Login\n",
|
|
279
|
+
},
|
|
280
|
+
{ path: "memory.md", kind: "agent_file", size: 5 },
|
|
281
|
+
{ path: "old.md", kind: "agent_file", size: 3 },
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await pushWorkflow({ remote, files: local.files }, dir);
|
|
287
|
+
|
|
288
|
+
expect(local.text(`${dir}/settings.yaml`)).toBe("start: Login\n");
|
|
289
|
+
const manifest = JSON.parse(local.text(`${dir}/${MANIFEST}`)!) as Manifest;
|
|
290
|
+
expect(manifest.rev).toBe(8);
|
|
291
|
+
expect(manifest.files["settings.yaml"]).toBe(
|
|
292
|
+
sha256(Buffer.from("start: Login\n")),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("sends nothing when the folder matches the pull", async () => {
|
|
297
|
+
const local = pulledHead({});
|
|
298
|
+
const { remote, calls } = fakeRemote({});
|
|
299
|
+
|
|
300
|
+
const result = await pushWorkflow({ remote, files: local.files }, dir);
|
|
301
|
+
|
|
302
|
+
expect(calls).toEqual([]);
|
|
303
|
+
expect(result).toMatchObject({ written: [], deleted: [], rev: 7 });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("tells the user to pull again when the head moved", async () => {
|
|
307
|
+
const local = pulledHead({ "memory.md": "edit" });
|
|
308
|
+
const { remote } = fakeRemote({
|
|
309
|
+
[`PATCH /agents/v2/workflows/${WF}/head/files`]: () => {
|
|
310
|
+
throw Object.assign(new Error("stale"), { status: 409 });
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const push = pushWorkflow({ remote, files: local.files }, dir);
|
|
315
|
+
|
|
316
|
+
await expect(push).rejects.toThrow(UsageError);
|
|
317
|
+
await expect(push).rejects.toThrow("changed since your pull");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("refuses a folder with no pull in it", async () => {
|
|
321
|
+
const { remote } = fakeRemote({});
|
|
322
|
+
|
|
323
|
+
const push = pushWorkflow({ remote, files: memoryFiles().files }, dir);
|
|
324
|
+
|
|
325
|
+
await expect(push).rejects.toThrow("no pulled workflow");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("refuses a pinned version", async () => {
|
|
329
|
+
const manifest = {
|
|
330
|
+
workflowId: WF,
|
|
331
|
+
versionId: HEAD,
|
|
332
|
+
head: false,
|
|
333
|
+
rev: 1,
|
|
334
|
+
files: {},
|
|
335
|
+
};
|
|
336
|
+
const local = memoryFiles({
|
|
337
|
+
[`${dir}/${MANIFEST}`]: JSON.stringify(manifest),
|
|
338
|
+
});
|
|
339
|
+
const { remote } = fakeRemote({});
|
|
340
|
+
|
|
341
|
+
const push = pushWorkflow({ remote, files: local.files }, dir);
|
|
342
|
+
|
|
343
|
+
await expect(push).rejects.toThrow("read-only");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
describe("pullExecution", () => {
|
|
348
|
+
const EX = "33333333-aaaa-bbbb-cccc-000000000000";
|
|
349
|
+
const execution = {
|
|
350
|
+
id: EX,
|
|
351
|
+
status: "completed",
|
|
352
|
+
workflowId: WF,
|
|
353
|
+
workflowVersionId: HEAD,
|
|
354
|
+
workflowName: "Fill Form",
|
|
355
|
+
createdAt: "2026-10-09T10:00:00Z",
|
|
356
|
+
executionResult: { outcome: "success", reasoning: "done" },
|
|
357
|
+
};
|
|
358
|
+
const file = (id: string, directory: string, name: string, size = 10) => ({
|
|
359
|
+
id,
|
|
360
|
+
fileName: name,
|
|
361
|
+
filePath: `org/${EX}/${directory}/${name}`,
|
|
362
|
+
fileSize: size,
|
|
363
|
+
directory,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
function setup(overrides: Record<string, unknown> = {}) {
|
|
367
|
+
const local = memoryFiles();
|
|
368
|
+
const { remote, downloads } = fakeRemote({
|
|
369
|
+
[`GET /agents/v2/executions/${EX}`]: () => ({
|
|
370
|
+
...execution,
|
|
371
|
+
...overrides,
|
|
372
|
+
}),
|
|
373
|
+
[`GET /agents/v2/executions/${EX}/activities`]: () => [
|
|
374
|
+
{ payload: { activityType: "generic", message: "hello" } },
|
|
375
|
+
],
|
|
376
|
+
[`GET /agents/v2/executions/${EX}/files`]: () => ({
|
|
377
|
+
directories: {
|
|
378
|
+
workspace: [file("a", "workspace", "out.csv")],
|
|
379
|
+
downloads: [file("b", "downloads", "big.zip", 5000)],
|
|
380
|
+
uploads: [file("c", "uploads", "input.pdf")],
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
[`GET /agents/v2/executions/${EX}/files/a/signed-url`]: () => ({
|
|
384
|
+
url: "https://s/a",
|
|
385
|
+
}),
|
|
386
|
+
[`GET /agents/v2/executions/${EX}/files/b/signed-url`]: () => ({
|
|
387
|
+
url: "https://s/b",
|
|
388
|
+
}),
|
|
389
|
+
});
|
|
390
|
+
return { sync: { remote, files: local.files }, local, downloads };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
it("writes the summary, activities and run files under the workflow's executions folder", async () => {
|
|
394
|
+
const { sync, local } = setup();
|
|
395
|
+
|
|
396
|
+
const result = await pullExecution(sync, { executionId: EX, root: "" });
|
|
397
|
+
|
|
398
|
+
const dir = "Fill_Form/executions/33333333";
|
|
399
|
+
expect(result.dir).toBe(dir);
|
|
400
|
+
expect(JSON.parse(local.text(`${dir}/summary.json`)!).id).toBe(EX);
|
|
401
|
+
expect(local.text(`${dir}/activities.md`)).toContain("[message]:\nhello");
|
|
402
|
+
expect(local.text(`${dir}/workspace/out.csv`)).toBe("bytes of https://s/a");
|
|
403
|
+
expect(local.text(`${dir}/downloads/big.zip`)).toBe("bytes of https://s/b");
|
|
404
|
+
expect(local.store.has(`${dir}/uploads/input.pdf`)).toBe(false);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("filters files by glob and size and names what it skipped", async () => {
|
|
408
|
+
const { sync, downloads } = setup();
|
|
409
|
+
|
|
410
|
+
const result = await pullExecution(sync, {
|
|
411
|
+
executionId: EX,
|
|
412
|
+
root: "",
|
|
413
|
+
glob: "**/*.zip",
|
|
414
|
+
maxFileBytes: 100,
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
expect(downloads).toEqual([]);
|
|
418
|
+
expect(result.skipped).toEqual([
|
|
419
|
+
"workspace/out.csv (excluded by --files)",
|
|
420
|
+
"downloads/big.zip (5000 bytes, over the size limit)",
|
|
421
|
+
]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("skips downloads with files off", async () => {
|
|
425
|
+
const { sync, downloads } = setup();
|
|
426
|
+
|
|
427
|
+
const result = await pullExecution(sync, {
|
|
428
|
+
executionId: EX,
|
|
429
|
+
root: "",
|
|
430
|
+
files: false,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(downloads).toEqual([]);
|
|
434
|
+
expect(formatExecutionPull(result)).toContain(
|
|
435
|
+
"downloads skipped (--no-files)",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("stops at the summary for a purged run", async () => {
|
|
440
|
+
const { sync, local } = setup({ purgedAt: "2026-10-01T00:00:00Z" });
|
|
441
|
+
|
|
442
|
+
const result = await pullExecution(sync, { executionId: EX, root: "" });
|
|
443
|
+
|
|
444
|
+
expect(result.written).toEqual(["summary.json"]);
|
|
445
|
+
expect(local.store.has("Fill_Form/executions/33333333/activities.md")).toBe(
|
|
446
|
+
false,
|
|
447
|
+
);
|
|
448
|
+
expect(formatExecutionPull(result)).toContain("retention-purged");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it("uses the folder a caller chooses", async () => {
|
|
452
|
+
const { sync } = setup();
|
|
453
|
+
|
|
454
|
+
const result = await pullExecution(sync, {
|
|
455
|
+
executionId: EX,
|
|
456
|
+
root: "",
|
|
457
|
+
files: false,
|
|
458
|
+
dir: () => "mine/executions/x",
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
expect(result.dir).toBe("mine/executions/x");
|
|
462
|
+
});
|
|
463
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { BuiltRequest } from "../request";
|
|
2
|
+
|
|
3
|
+
export interface LocalFiles {
|
|
4
|
+
read(path: string): Promise<Uint8Array | undefined>;
|
|
5
|
+
write(path: string, bytes: Uint8Array): Promise<void>;
|
|
6
|
+
remove(path: string): Promise<void>;
|
|
7
|
+
list(dir: string): Promise<string[]>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Remote {
|
|
11
|
+
json(request: BuiltRequest): Promise<unknown>;
|
|
12
|
+
download(url: string): Promise<Uint8Array>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Sync {
|
|
16
|
+
remote: Remote;
|
|
17
|
+
files: LocalFiles;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FileEntry {
|
|
21
|
+
path: string;
|
|
22
|
+
kind: string;
|
|
23
|
+
size: number;
|
|
24
|
+
content?: string;
|
|
25
|
+
contentBase64?: string;
|
|
26
|
+
checksum?: string;
|
|
27
|
+
fileId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileTree {
|
|
31
|
+
rev: number;
|
|
32
|
+
version?: number;
|
|
33
|
+
versionId: string;
|
|
34
|
+
workflowName: string;
|
|
35
|
+
files: FileEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MANIFEST = ".asteroid.json";
|
|
39
|
+
|
|
40
|
+
export interface Manifest {
|
|
41
|
+
workflowId: string;
|
|
42
|
+
versionId: string;
|
|
43
|
+
head: boolean;
|
|
44
|
+
rev: number;
|
|
45
|
+
files: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function get(
|
|
49
|
+
path: string,
|
|
50
|
+
query: Record<string, string> = {},
|
|
51
|
+
): BuiltRequest {
|
|
52
|
+
return { method: "GET", path, query: new URLSearchParams(query) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function statusOf(error: unknown): number | undefined {
|
|
56
|
+
const status = (error as { status?: unknown } | null)?.status;
|
|
57
|
+
return typeof status === "number" ? status : undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isContained,
|
|
3
|
+
join,
|
|
4
|
+
sha256,
|
|
5
|
+
workflowDirName,
|
|
6
|
+
workflowRoot,
|
|
7
|
+
} from "./paths";
|
|
8
|
+
import {
|
|
9
|
+
type FileEntry,
|
|
10
|
+
type FileTree,
|
|
11
|
+
get,
|
|
12
|
+
MANIFEST,
|
|
13
|
+
type Manifest,
|
|
14
|
+
type Sync,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
const CONCURRENCY = 16;
|
|
18
|
+
|
|
19
|
+
export interface WorkflowPullOptions {
|
|
20
|
+
workflowId: string;
|
|
21
|
+
versionId?: string;
|
|
22
|
+
root: string;
|
|
23
|
+
dir?: string;
|
|
24
|
+
manifest?: boolean;
|
|
25
|
+
maxFileBytes?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkflowPullResult {
|
|
29
|
+
workflowId: string;
|
|
30
|
+
workflowName: string;
|
|
31
|
+
versionId: string;
|
|
32
|
+
version?: number;
|
|
33
|
+
rev: number;
|
|
34
|
+
dir: string;
|
|
35
|
+
written: number;
|
|
36
|
+
shared: number;
|
|
37
|
+
skipped: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function treeRequest(options: WorkflowPullOptions) {
|
|
41
|
+
const base = `/agents/v2/workflows/${options.workflowId}`;
|
|
42
|
+
const path = options.versionId
|
|
43
|
+
? `${base}/versions/${options.versionId}/files`
|
|
44
|
+
: `${base}/head/files`;
|
|
45
|
+
return get(path, { contents: "all" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function pullDir(options: WorkflowPullOptions, tree: FileTree): string {
|
|
49
|
+
if (options.dir) return options.dir;
|
|
50
|
+
const root = workflowRoot(options.root, tree.workflowName);
|
|
51
|
+
if (!options.versionId) return join(root, "head");
|
|
52
|
+
return join(root, ".versions", workflowDirName(tree.versionId, tree.version));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function agentFileBytes(
|
|
56
|
+
sync: Sync,
|
|
57
|
+
workflowId: string,
|
|
58
|
+
tree: FileTree,
|
|
59
|
+
entry: FileEntry,
|
|
60
|
+
): Promise<Uint8Array> {
|
|
61
|
+
const path = `/agents/v2/workflows/${workflowId}/versions/${tree.versionId}/files/${entry.fileId}/signed-url`;
|
|
62
|
+
const { url } = (await sync.remote.json(get(path))) as { url: string };
|
|
63
|
+
return sync.remote.download(url);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function entryBytes(
|
|
67
|
+
sync: Sync,
|
|
68
|
+
workflowId: string,
|
|
69
|
+
tree: FileTree,
|
|
70
|
+
entry: FileEntry,
|
|
71
|
+
): Promise<Uint8Array | undefined> {
|
|
72
|
+
if (entry.contentBase64 !== undefined)
|
|
73
|
+
return Buffer.from(entry.contentBase64, "base64");
|
|
74
|
+
if (entry.content !== undefined) return Buffer.from(entry.content, "utf8");
|
|
75
|
+
if (!entry.fileId) return undefined;
|
|
76
|
+
return agentFileBytes(sync, workflowId, tree, entry);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeEntries(
|
|
80
|
+
sync: Sync,
|
|
81
|
+
options: WorkflowPullOptions,
|
|
82
|
+
tree: FileTree,
|
|
83
|
+
dir: string,
|
|
84
|
+
) {
|
|
85
|
+
const hashes: Record<string, string> = {};
|
|
86
|
+
const skipped: string[] = [];
|
|
87
|
+
let shared = 0;
|
|
88
|
+
const wanted = tree.files.filter((entry) => {
|
|
89
|
+
if (!isContained(entry.path)) skipped.push(`${entry.path} (unsafe path)`);
|
|
90
|
+
else if (
|
|
91
|
+
options.maxFileBytes !== undefined &&
|
|
92
|
+
entry.size > options.maxFileBytes
|
|
93
|
+
)
|
|
94
|
+
skipped.push(`${entry.path} (${entry.size} bytes, over the size limit)`);
|
|
95
|
+
else return true;
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
for (let i = 0; i < wanted.length; i += CONCURRENCY) {
|
|
99
|
+
await Promise.all(
|
|
100
|
+
wanted.slice(i, i + CONCURRENCY).map(async (entry) => {
|
|
101
|
+
const bytes = await entryBytes(sync, options.workflowId, tree, entry);
|
|
102
|
+
if (!bytes) return void skipped.push(`${entry.path} (no content)`);
|
|
103
|
+
await sync.files.write(join(dir, entry.path), bytes);
|
|
104
|
+
hashes[entry.path] = sha256(bytes);
|
|
105
|
+
if (entry.kind === "agent_file") shared++;
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return { hashes, skipped, shared };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function removeStale(sync: Sync, dir: string, keep: Set<string>) {
|
|
113
|
+
for (const path of await sync.files.list(dir)) {
|
|
114
|
+
if (!keep.has(path) && path !== MANIFEST)
|
|
115
|
+
await sync.files.remove(join(dir, path));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function writeManifest(
|
|
120
|
+
sync: Sync,
|
|
121
|
+
dir: string,
|
|
122
|
+
manifest: Manifest,
|
|
123
|
+
) {
|
|
124
|
+
const text = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
125
|
+
await sync.files.write(join(dir, MANIFEST), Buffer.from(text, "utf8"));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function pullWorkflow(
|
|
129
|
+
sync: Sync,
|
|
130
|
+
options: WorkflowPullOptions,
|
|
131
|
+
): Promise<WorkflowPullResult> {
|
|
132
|
+
const tree = (await sync.remote.json(treeRequest(options))) as FileTree;
|
|
133
|
+
const dir = pullDir(options, tree);
|
|
134
|
+
const { hashes, skipped, shared } = await writeEntries(
|
|
135
|
+
sync,
|
|
136
|
+
options,
|
|
137
|
+
tree,
|
|
138
|
+
dir,
|
|
139
|
+
);
|
|
140
|
+
await removeStale(sync, dir, new Set(Object.keys(hashes)));
|
|
141
|
+
if (options.manifest !== false) {
|
|
142
|
+
await writeManifest(sync, dir, {
|
|
143
|
+
workflowId: options.workflowId,
|
|
144
|
+
versionId: tree.versionId,
|
|
145
|
+
head: !options.versionId,
|
|
146
|
+
rev: tree.rev,
|
|
147
|
+
files: hashes,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
workflowId: options.workflowId,
|
|
152
|
+
workflowName: tree.workflowName,
|
|
153
|
+
versionId: tree.versionId,
|
|
154
|
+
version: tree.version,
|
|
155
|
+
rev: tree.rev,
|
|
156
|
+
dir,
|
|
157
|
+
written: Object.keys(hashes).length,
|
|
158
|
+
shared,
|
|
159
|
+
skipped,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatWorkflowPull(result: WorkflowPullResult): string {
|
|
164
|
+
const definition = result.written - result.shared;
|
|
165
|
+
const files =
|
|
166
|
+
result.written === 0
|
|
167
|
+
? "Files: none written"
|
|
168
|
+
: result.shared === 0
|
|
169
|
+
? `Files: ${result.written} written`
|
|
170
|
+
: `Files: ${result.written} written (${definition} workflow definition, ${result.shared} shared)`;
|
|
171
|
+
return [
|
|
172
|
+
`Workflow: ${result.workflowName} (${result.workflowId})`,
|
|
173
|
+
`Workflow version: ${result.versionId}${result.version ? ` (v${result.version})` : " (draft)"}`,
|
|
174
|
+
`Written to: ${result.dir}/`,
|
|
175
|
+
files,
|
|
176
|
+
result.skipped.length > 0
|
|
177
|
+
? `Skipped:\n${result.skipped.map((s) => ` - ${s}`).join("\n")}`
|
|
178
|
+
: null,
|
|
179
|
+
]
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.join("\n");
|
|
182
|
+
}
|