@asteroidai/cli 1.9.4 → 1.9.12

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/src/run.test.ts CHANGED
@@ -1,11 +1,11 @@
1
- import { mkdtempSync, readFileSync } from "node:fs";
1
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { beforeEach, describe, expect, it } from "vitest";
5
5
 
6
6
  import { CredentialStore } from "./auth/store";
7
+ import { ALL_COMMANDS } from "./commands";
7
8
  import { readEnv, saveConfig } from "./config";
8
- import { COMMANDS } from "./generated/commands";
9
9
  import { renderReference } from "./reference";
10
10
  import { type Deps, run } from "./run";
11
11
 
@@ -323,12 +323,109 @@ describe("terminal safety", () => {
323
323
  });
324
324
  });
325
325
 
326
+ describe("files --out", () => {
327
+ it("writes a pinned version into the folder it is given", async () => {
328
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () =>
329
+ json({
330
+ rev: 1,
331
+ version: 2,
332
+ versionId: "v-2",
333
+ workflowName: "Fill form",
334
+ files: [
335
+ {
336
+ path: "settings.yaml",
337
+ kind: "structural",
338
+ size: 3,
339
+ content: "a: 1",
340
+ },
341
+ ],
342
+ }),
343
+ );
344
+ const dir = join(h.configDir, "pulled");
345
+
346
+ const code = await run(
347
+ ["workflow", "version", "pull", "w1", "v-2", "--out", dir],
348
+ h.deps,
349
+ );
350
+
351
+ expect(code).toBe(0);
352
+ expect(h.calls[0].url).toBe(
353
+ "https://odyssey.asteroid.ai/agents/v2/workflows/w1/versions/v-2/files?contents=all",
354
+ );
355
+ expect(readFileSync(join(dir, "settings.yaml"), "utf8")).toBe("a: 1");
356
+ });
357
+ });
358
+
359
+ describe("workflow pull and push", () => {
360
+ const tree = {
361
+ rev: 1,
362
+ version: 2,
363
+ versionId: "v-2",
364
+ workflowName: "Fill form",
365
+ files: [
366
+ { path: "settings.yaml", kind: "structural", size: 4, content: "a: 1" },
367
+ ],
368
+ };
369
+
370
+ it("pulls into ./<workflow-name>/head and pushes from there with no arguments", async () => {
371
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
372
+ call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
373
+ );
374
+
375
+ expect(await run(["workflow", "pull", "w1"], h.deps)).toBe(0);
376
+ const head = join(h.configDir, "Fill_form", "head");
377
+ expect(readFileSync(join(head, "settings.yaml"), "utf8")).toBe("a: 1");
378
+
379
+ writeFileSync(join(head, "settings.yaml"), "a: 2");
380
+ const code = await run(["workflow", "push"], { ...h.deps, cwd: head });
381
+
382
+ expect(code).toBe(0);
383
+ const patch = h.calls.find((call) => call.init.method === "PATCH")!;
384
+ expect(patch.url).toBe(
385
+ "https://odyssey.asteroid.ai/agents/v2/workflows/w1/head/files",
386
+ );
387
+ expect(JSON.parse(String(patch.init.body))).toMatchObject({ baseRev: 2 });
388
+ });
389
+
390
+ it("pushes the folder --from names, taking the workflow from its manifest", async () => {
391
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, (call) =>
392
+ call.init.method === "PATCH" ? json({}) : json({ ...tree, rev: 2 }),
393
+ );
394
+ await run(["workflow", "pull", "w9"], h.deps);
395
+ const head = join(h.configDir, "Fill_form", "head");
396
+ writeFileSync(join(head, "settings.yaml"), "a: 3");
397
+
398
+ const code = await run(["workflow", "push", "--from", head], h.deps);
399
+
400
+ expect(code).toBe(0);
401
+ const patch = h.calls.find((call) => call.init.method === "PATCH")!;
402
+ expect(patch.url).toBe(
403
+ "https://odyssey.asteroid.ai/agents/v2/workflows/w9/head/files",
404
+ );
405
+ });
406
+ });
407
+
408
+ describe("workflow pull with a filter", () => {
409
+ it("refuses a partial tree, which would delete the files it leaves out", async () => {
410
+ const h = harness({ ASTEROID_API_KEY: "sk_test" }, () => json({}));
411
+
412
+ const code = await run(
413
+ ["workflow", "pull", "w1", "--paths", "a.md"],
414
+ h.deps,
415
+ );
416
+
417
+ expect(code).toBe(2);
418
+ expect(h.calls).toEqual([]);
419
+ expect(h.err.join("")).toContain("cannot take --paths");
420
+ });
421
+ });
422
+
326
423
  describe("reference", () => {
327
424
  it("matches the generated agent-docs page", () => {
328
425
  const page = readFileSync(
329
426
  new URL("../../../docs/cli/reference.mdx", import.meta.url),
330
427
  "utf8",
331
428
  );
332
- expect(page).toBe(renderReference(COMMANDS));
429
+ expect(page).toBe(renderReference(ALL_COMMANDS));
333
430
  });
334
431
  });
package/src/run.ts CHANGED
@@ -10,13 +10,15 @@ import {
10
10
  UNSET_API_KEY,
11
11
  } from "./auth/credentials";
12
12
  import { WorkosError } from "./auth/workos";
13
+ import { ALL_COMMANDS } from "./commands";
13
14
  import { type Env, loadConfig, orgLabel, requestedOrg } from "./config";
14
- import { COMMANDS } from "./generated/commands";
15
15
  import { ApiError, send, VERSION } from "./http";
16
16
  import { clean, render } from "./output";
17
17
  import { parseInvocation } from "./program";
18
18
  import { BUILTINS } from "./reference";
19
19
  import { buildRequest } from "./request";
20
+ import { runsLocally, runSync } from "./sync/run";
21
+ import type { Remote } from "./sync/types";
20
22
  import type { CliCommand } from "./types";
21
23
  import type { Ui } from "./ui";
22
24
 
@@ -82,6 +84,28 @@ async function credentialOrganization(
82
84
  ?.organizationId;
83
85
  }
84
86
 
87
+ async function authFor(parsed: Parsed, deps: Deps): Promise<Auth> {
88
+ rejectOrgWithApiKey(deps, parsed.org);
89
+ const config = loadConfig(deps.env);
90
+ return resolveAuth(
91
+ deps.env,
92
+ config,
93
+ requestedOrg(parsed.org, deps.env, deps.cwd, config),
94
+ deps.fetch,
95
+ );
96
+ }
97
+
98
+ function announce(command: CliCommand, auth: Auth, deps: Deps) {
99
+ if (command.readOnly) return;
100
+ const org =
101
+ auth.kind === "api-key"
102
+ ? "the API key's organization"
103
+ : auth.org
104
+ ? orgLabel(auth.org)
105
+ : "your default organization";
106
+ deps.stderr(`Acting in ${org}.\n`);
107
+ }
108
+
85
109
  async function runCommand(
86
110
  command: CliCommand,
87
111
  parsed: Parsed,
@@ -91,26 +115,11 @@ async function runCommand(
91
115
  let request = needsOrganization
92
116
  ? undefined
93
117
  : await buildRequest(command, parsed);
94
- rejectOrgWithApiKey(deps, parsed.org);
95
- const config = loadConfig(deps.env);
96
- const auth = await resolveAuth(
97
- deps.env,
98
- config,
99
- requestedOrg(parsed.org, deps.env, deps.cwd, config),
100
- deps.fetch,
101
- );
118
+ const auth = await authFor(parsed, deps);
102
119
  request ??= await buildRequest(command, parsed, {
103
120
  organizationId: await credentialOrganization(deps, auth),
104
121
  });
105
- if (!command.readOnly) {
106
- const org =
107
- auth.kind === "api-key"
108
- ? "the API key's organization"
109
- : auth.org
110
- ? orgLabel(auth.org)
111
- : "your default organization";
112
- deps.stderr(`Acting in ${org}.\n`);
113
- }
122
+ announce(command, auth, deps);
114
123
 
115
124
  const response = await send(
116
125
  deps.env.apiUrl,
@@ -153,16 +162,37 @@ async function runCommand(
153
162
  return 0;
154
163
  }
155
164
 
165
+ function nodeRemote(
166
+ apiUrl: string,
167
+ auth: Auth,
168
+ fetchImpl: typeof fetch,
169
+ ): Remote {
170
+ return {
171
+ json: async (request) =>
172
+ (await send(apiUrl, auth, request, false, fetchImpl)).json,
173
+ async download(url) {
174
+ const response = await fetchImpl(url);
175
+ if (!response.ok)
176
+ throw new Error(`download failed: HTTP ${response.status}`);
177
+ return new Uint8Array(await response.arrayBuffer());
178
+ },
179
+ };
180
+ }
181
+
156
182
  const DESCRIPTION =
157
183
  "The Asteroid CLI. Sign in with `asteroid login`, or set ASTEROID_API_KEY.\nAt a terminal, tables show up to 6 columns and cut long cells to fit; when output is piped, they show every column in full. Use `--json` for every field.";
158
184
 
159
185
  export async function run(argv: string[], deps: Deps): Promise<number> {
160
186
  try {
161
187
  const invocation = await parseInvocation(argv, {
162
- commands: COMMANDS,
188
+ commands: ALL_COMMANDS,
163
189
  builtins: BUILTINS,
164
190
  version: VERSION,
165
191
  description: DESCRIPTION,
192
+ defaultArg: (command, arg) =>
193
+ command.files === "push" && arg.name === "workflowId" ? "" : undefined,
194
+ defaultArgNote:
195
+ "<workflow-id> defaults to the workflow the pushed folder was pulled from.",
166
196
  });
167
197
  if (invocation.kind === "exit") {
168
198
  if (invocation.stdout) deps.stdout(invocation.stdout);
@@ -172,6 +202,14 @@ export async function run(argv: string[], deps: Deps): Promise<number> {
172
202
  if (invocation.kind === "builtin") {
173
203
  return await runBuiltin(invocation.name, invocation.parsed, deps);
174
204
  }
205
+ if (runsLocally(invocation.command, invocation.parsed)) {
206
+ const auth = await authFor(invocation.parsed, deps);
207
+ announce(invocation.command, auth, deps);
208
+ return await runSync(invocation.command, invocation.parsed, {
209
+ ...deps,
210
+ remote: nodeRemote(deps.env.apiUrl, auth, deps.fetch),
211
+ });
212
+ }
175
213
  return await runCommand(invocation.command, invocation.parsed, deps);
176
214
  } catch (error) {
177
215
  if (error instanceof UsageError) {
@@ -0,0 +1,80 @@
1
+ interface Payload {
2
+ activityType: string;
3
+ display?: { turnId?: string; summaryText?: string };
4
+ reasoning?: string;
5
+ message?: string;
6
+ actionName?: string;
7
+ info?: Record<string, unknown>;
8
+ summaryTokens?: number;
9
+ durationMs?: number;
10
+ }
11
+
12
+ export interface Activity {
13
+ payload: Payload;
14
+ }
15
+
16
+ function indentJson(data: Record<string, unknown>): string {
17
+ return JSON.stringify(data, null, 2)
18
+ .split("\n")
19
+ .map((line) => ` ${line}`)
20
+ .join("\n");
21
+ }
22
+
23
+ function action(label: string, payload: Payload): string {
24
+ const info = payload.info;
25
+ const detail =
26
+ info && Object.keys(info).length > 0 ? `\ninfo:\n${indentJson(info)}` : "";
27
+ return `[${label}: ${payload.actionName}]:${detail}\n`;
28
+ }
29
+
30
+ function line(payload: Payload): string {
31
+ switch (payload.activityType) {
32
+ case "reasoning":
33
+ return `[reasoning]:\n${payload.reasoning}\n`;
34
+ case "generic":
35
+ return `[message]:\n${payload.message}\n`;
36
+ case "user_message_received":
37
+ return `[user_message]:\n${payload.message}\n`;
38
+ case "action_started":
39
+ return action("action_started", payload);
40
+ case "action_completed":
41
+ return action("action_completed", payload);
42
+ case "action_failed":
43
+ return `[action_failed: ${payload.actionName}]:\nfailure: ${payload.message}\n`;
44
+ case "compaction_performed":
45
+ return `[compaction]: summary_tokens: ${payload.summaryTokens}, duration_ms: ${payload.durationMs}\n`;
46
+ case "compaction_started":
47
+ return "[compaction_started]\n";
48
+ default: {
49
+ const summary = payload.display?.summaryText;
50
+ return summary
51
+ ? `[${payload.activityType}]: ${summary}\n`
52
+ : `[${payload.activityType}]\n`;
53
+ }
54
+ }
55
+ }
56
+
57
+ export function formatActivitiesMarkdown(
58
+ activities: Activity[],
59
+ status: string,
60
+ createdAt: string,
61
+ ): string {
62
+ const lines = [
63
+ "# Execution Activities\n",
64
+ `Status: ${status}`,
65
+ `Started: ${createdAt}`,
66
+ ];
67
+ let currentTurn = "";
68
+ for (const { payload } of activities) {
69
+ const turnId = payload.display?.turnId ?? "";
70
+ if (turnId && turnId !== currentTurn) {
71
+ currentTurn = turnId;
72
+ lines.push(`\n---\n\n## ${turnId}\n`);
73
+ } else if (!currentTurn && !turnId) {
74
+ currentTurn = "_started";
75
+ lines.push("\n---\n");
76
+ }
77
+ lines.push(line(payload));
78
+ }
79
+ return lines.join("\n");
80
+ }
@@ -0,0 +1,63 @@
1
+ import type { CliCommand, Option } from "../types";
2
+
3
+ function option(
4
+ name: string,
5
+ type: Option["type"],
6
+ description: string,
7
+ extra: Partial<Option> = {},
8
+ ): Option {
9
+ return { name, flag: name, type, required: false, description, ...extra };
10
+ }
11
+
12
+ const local = {
13
+ method: "GET",
14
+ path: "",
15
+ binary: false,
16
+ destructive: false,
17
+ local: true,
18
+ } as const;
19
+
20
+ export const SYNC_COMMANDS: CliCommand[] = [
21
+ {
22
+ ...local,
23
+ command: "execution pull",
24
+ tool: "execution_pull",
25
+ title: "Pull an execution to disk",
26
+ description:
27
+ "Write an execution to `<workflow-name>/executions/<id>/`: summary.json, activities.md (the step-by-step log), and the files the run produced.",
28
+ readOnly: true,
29
+ args: [
30
+ {
31
+ name: "executionId",
32
+ flag: "execution-id",
33
+ type: "string",
34
+ required: true,
35
+ },
36
+ ],
37
+ query: [
38
+ option(
39
+ "files",
40
+ "string",
41
+ "Only download files whose path matches this glob. --no-files skips downloads.",
42
+ {
43
+ negatable: true,
44
+ },
45
+ ),
46
+ option(
47
+ "summary-only",
48
+ "boolean",
49
+ "Write summary.json only: no activities, no files.",
50
+ ),
51
+ option(
52
+ "max-file-size",
53
+ "integer",
54
+ "Skip files larger than this many bytes.",
55
+ ),
56
+ option(
57
+ "dir",
58
+ "string",
59
+ "Write here instead of `<workflow-name>/executions/<id>/` under the current directory.",
60
+ ),
61
+ ],
62
+ },
63
+ ];
@@ -0,0 +1,273 @@
1
+ import { type Activity, formatActivitiesMarkdown } from "./activities";
2
+ import {
3
+ executionDirName,
4
+ isContained,
5
+ join,
6
+ matchesGlob,
7
+ workflowRoot,
8
+ } from "./paths";
9
+ import { get, type Sync } from "./types";
10
+
11
+ export interface Execution {
12
+ id: string;
13
+ status: string;
14
+ workflowId: string;
15
+ workflowVersionId?: string;
16
+ workflowName?: string;
17
+ inputs?: unknown;
18
+ executionResult?: { outcome?: string; reasoning?: string } | null;
19
+ createdAt: string;
20
+ terminalAt?: string | null;
21
+ duration?: number | null;
22
+ scriptFailures?: { nodeName?: string; error?: string }[] | null;
23
+ purgedAt?: string | null;
24
+ }
25
+
26
+ interface ExecutionFile {
27
+ id: string;
28
+ fileName: string;
29
+ filePath: string;
30
+ fileSize: number;
31
+ directory: string;
32
+ }
33
+
34
+ const DIRECTORIES = ["workspace", "downloads", "shared"] as const;
35
+
36
+ export interface ExecutionPullOptions {
37
+ executionId: string;
38
+ root: string;
39
+ dir?: (execution: Execution) => string | undefined;
40
+ files?: boolean;
41
+ activities?: boolean;
42
+ glob?: string;
43
+ maxFileBytes?: number;
44
+ }
45
+
46
+ export interface ExecutionPullResult {
47
+ execution: Execution;
48
+ dir: string;
49
+ written: string[];
50
+ downloaded: number;
51
+ candidates: number;
52
+ skipped: string[];
53
+ failed: string[];
54
+ skippedBy?: string;
55
+ }
56
+
57
+ export function relativeFilePath(
58
+ file: Pick<ExecutionFile, "filePath" | "directory">,
59
+ ): string {
60
+ const at = file.filePath.indexOf(`/${file.directory}/`);
61
+ if (at === -1)
62
+ return `${file.directory}/${file.filePath.split("/").pop() ?? "unknown"}`;
63
+ return file.filePath
64
+ .slice(at + 1)
65
+ .replace(/^workspace\/\.execution-context\//, "");
66
+ }
67
+
68
+ function executionDir(
69
+ options: ExecutionPullOptions,
70
+ execution: Execution,
71
+ ): string {
72
+ const chosen = options.dir?.(execution);
73
+ if (chosen) return chosen;
74
+ const root = workflowRoot(
75
+ options.root,
76
+ execution.workflowName ?? execution.workflowId,
77
+ );
78
+ return join(root, "executions", executionDirName(execution.id));
79
+ }
80
+
81
+ async function writeText(sync: Sync, path: string, text: string) {
82
+ await sync.files.write(path, Buffer.from(text, "utf8"));
83
+ }
84
+
85
+ async function writeActivities(sync: Sync, dir: string, execution: Execution) {
86
+ try {
87
+ const activities = (await sync.remote.json(
88
+ get(`/agents/v2/executions/${execution.id}/activities`, { order: "asc" }),
89
+ )) as Activity[];
90
+ if (activities.length === 0) return false;
91
+ const markdown = formatActivitiesMarkdown(
92
+ activities,
93
+ execution.status,
94
+ execution.createdAt,
95
+ );
96
+ await writeText(sync, join(dir, "activities.md"), markdown);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ async function listFiles(
104
+ sync: Sync,
105
+ executionId: string,
106
+ ): Promise<ExecutionFile[]> {
107
+ const listing = (await sync.remote.json(
108
+ get(`/agents/v2/executions/${executionId}/files`),
109
+ )) as {
110
+ directories: Partial<Record<string, ExecutionFile[]>>;
111
+ };
112
+ return DIRECTORIES.flatMap((name) => listing.directories[name] ?? []);
113
+ }
114
+
115
+ async function download(sync: Sync, executionId: string, file: ExecutionFile) {
116
+ const path = `/agents/v2/executions/${executionId}/files/${file.id}/signed-url`;
117
+ const { url } = (await sync.remote.json(get(path))) as { url: string };
118
+ return sync.remote.download(url);
119
+ }
120
+
121
+ function selectFiles(
122
+ files: ExecutionFile[],
123
+ options: ExecutionPullOptions,
124
+ skipped: string[],
125
+ ) {
126
+ return files.filter((file) => {
127
+ const path = relativeFilePath(file);
128
+ if (!isContained(path)) skipped.push(`${path} (unsafe path)`);
129
+ else if (options.glob !== undefined && !matchesGlob(path, options.glob))
130
+ skipped.push(`${path} (excluded by --files)`);
131
+ else if (
132
+ options.maxFileBytes !== undefined &&
133
+ file.fileSize > options.maxFileBytes
134
+ )
135
+ skipped.push(`${path} (${file.fileSize} bytes, over the size limit)`);
136
+ else return true;
137
+ return false;
138
+ });
139
+ }
140
+
141
+ async function pullFiles(
142
+ sync: Sync,
143
+ dir: string,
144
+ options: ExecutionPullOptions,
145
+ ) {
146
+ const skipped: string[] = [];
147
+ const failed: string[] = [];
148
+ let files: ExecutionFile[];
149
+ try {
150
+ files = await listFiles(sync, options.executionId);
151
+ } catch (error) {
152
+ const reason = error instanceof Error ? error.message : String(error);
153
+ return {
154
+ downloaded: 0,
155
+ candidates: 0,
156
+ skipped,
157
+ failed: [`file listing (${reason})`],
158
+ };
159
+ }
160
+ let downloaded = 0;
161
+ await Promise.all(
162
+ selectFiles(files, options, skipped).map(async (file) => {
163
+ try {
164
+ const bytes = await download(sync, options.executionId, file);
165
+ await sync.files.write(join(dir, relativeFilePath(file)), bytes);
166
+ downloaded++;
167
+ } catch (error) {
168
+ failed.push(
169
+ `${file.fileName} (${error instanceof Error ? error.message : String(error)})`,
170
+ );
171
+ }
172
+ }),
173
+ );
174
+ return { downloaded, candidates: files.length, skipped, failed };
175
+ }
176
+
177
+ export async function pullExecution(
178
+ sync: Sync,
179
+ options: ExecutionPullOptions,
180
+ ): Promise<ExecutionPullResult> {
181
+ const execution = (await sync.remote.json(
182
+ get(`/agents/v2/executions/${options.executionId}`),
183
+ )) as Execution;
184
+ const dir = executionDir(options, execution);
185
+ await writeText(
186
+ sync,
187
+ join(dir, "summary.json"),
188
+ JSON.stringify(execution, null, 2),
189
+ );
190
+ const result = {
191
+ execution,
192
+ dir,
193
+ written: ["summary.json"],
194
+ downloaded: 0,
195
+ candidates: 0,
196
+ skipped: [],
197
+ failed: [],
198
+ };
199
+ if (execution.purgedAt) return result;
200
+ if (
201
+ options.activities !== false &&
202
+ (await writeActivities(sync, dir, execution))
203
+ )
204
+ result.written.push("activities.md");
205
+ if (options.files === false) {
206
+ return {
207
+ ...result,
208
+ skippedBy: options.activities === false ? "--summary-only" : "--no-files",
209
+ };
210
+ }
211
+ return { ...result, ...(await pullFiles(sync, dir, options)) };
212
+ }
213
+
214
+ function filesLine(result: ExecutionPullResult): string {
215
+ const parts = [...result.written];
216
+ if (result.downloaded > 0) parts.push(`${result.downloaded} downloaded`);
217
+ const counted = `Files: ${result.written.length + result.downloaded} written (${parts.join(", ")})`;
218
+ if (result.skippedBy)
219
+ return `${counted}; downloads skipped (${result.skippedBy})`;
220
+ if (result.candidates === 0) return `${counted}; no execution files matched`;
221
+ return counted;
222
+ }
223
+
224
+ function failures(execution: Execution): string | null {
225
+ const list = execution.scriptFailures ?? [];
226
+ if (list.length === 0) return null;
227
+ const lines = list.map(
228
+ (f) =>
229
+ ` - ${f.nodeName ?? "(unknown node)"}: ${(f.error ?? "").split("\n")[0]}`,
230
+ );
231
+ return `Script failures: ${list.length}\n${lines.join("\n")}`;
232
+ }
233
+
234
+ function purged(result: ExecutionPullResult): string {
235
+ const { execution } = result;
236
+ return [
237
+ `Execution: ${execution.id}`,
238
+ `Status: ${execution.status}`,
239
+ `Purged: ${execution.purgedAt}`,
240
+ "This run was retention-purged. Activities, files, inputs and result are gone. Do not diagnose it.",
241
+ `Written to: ${result.dir}/`,
242
+ ].join("\n");
243
+ }
244
+
245
+ export function formatExecutionPull(result: ExecutionPullResult): string {
246
+ const { execution } = result;
247
+ if (execution.purgedAt) return purged(result);
248
+ const outcome = execution.executionResult;
249
+ return [
250
+ `Execution: ${execution.id}`,
251
+ `Status: ${execution.status}`,
252
+ `Workflow: ${execution.workflowName ?? execution.workflowId}`,
253
+ execution.workflowVersionId
254
+ ? `Workflow version: ${execution.workflowVersionId}`
255
+ : null,
256
+ execution.inputs ? `Inputs: ${JSON.stringify(execution.inputs)}` : null,
257
+ outcome ? `Result: ${outcome.outcome}: ${outcome.reasoning}` : null,
258
+ `Created: ${execution.createdAt}`,
259
+ execution.terminalAt ? `Completed: ${execution.terminalAt}` : null,
260
+ execution.duration != null
261
+ ? `Duration: ${execution.duration.toFixed(1)}s`
262
+ : null,
263
+ failures(execution),
264
+ filesLine(result),
265
+ result.skipped.length > 0
266
+ ? `Skipped:\n${result.skipped.map((s) => ` - ${s}`).join("\n")}`
267
+ : null,
268
+ result.failed.length > 0 ? `Failed: ${result.failed.join(", ")}` : null,
269
+ `Written to: ${result.dir}/`,
270
+ ]
271
+ .filter(Boolean)
272
+ .join("\n");
273
+ }