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