@relayflows/sdk 2.0.48 → 2.0.50

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.
Files changed (61) hide show
  1. package/dist/agent-artifacts.d.ts +11 -0
  2. package/dist/agent-artifacts.d.ts.map +1 -1
  3. package/dist/agent-artifacts.js +32 -1
  4. package/dist/agent-artifacts.js.map +1 -1
  5. package/dist/agent-transcript.d.ts +1 -0
  6. package/dist/agent-transcript.d.ts.map +1 -1
  7. package/dist/agent-transcript.js +21 -4
  8. package/dist/agent-transcript.js.map +1 -1
  9. package/dist/authored-worker-step.d.ts.map +1 -1
  10. package/dist/authored-worker-step.js +10 -1
  11. package/dist/authored-worker-step.js.map +1 -1
  12. package/dist/cli/cloud-run.d.ts +2 -1
  13. package/dist/cli/cloud-run.d.ts.map +1 -1
  14. package/dist/cli/cloud-run.js +6 -2
  15. package/dist/cli/cloud-run.js.map +1 -1
  16. package/dist/cli-commands.d.ts +4 -1
  17. package/dist/cli-commands.d.ts.map +1 -1
  18. package/dist/cli-commands.js +5 -1
  19. package/dist/cli-commands.js.map +1 -1
  20. package/dist/cli.d.ts +1 -0
  21. package/dist/cli.d.ts.map +1 -1
  22. package/dist/cli.js +26 -7
  23. package/dist/cli.js.map +1 -1
  24. package/dist/cloud-run.d.ts +5 -0
  25. package/dist/cloud-run.d.ts.map +1 -1
  26. package/dist/cloud-run.js +11 -0
  27. package/dist/cloud-run.js.map +1 -1
  28. package/dist/local-agent-pins.d.ts +7 -0
  29. package/dist/local-agent-pins.d.ts.map +1 -0
  30. package/dist/local-agent-pins.js +7 -0
  31. package/dist/local-agent-pins.js.map +1 -0
  32. package/dist/local-agent.d.ts +1 -1
  33. package/dist/local-agent.d.ts.map +1 -1
  34. package/dist/local-agent.js +8 -4
  35. package/dist/local-agent.js.map +1 -1
  36. package/dist/observer-link.d.ts +7 -7
  37. package/dist/observer-link.js +6 -6
  38. package/dist/worker-cli.d.ts +12 -0
  39. package/dist/worker-cli.d.ts.map +1 -1
  40. package/dist/worker-cli.js +23 -4
  41. package/dist/worker-cli.js.map +1 -1
  42. package/dist/worker-input.d.ts.map +1 -1
  43. package/dist/worker-input.js +14 -2
  44. package/dist/worker-input.js.map +1 -1
  45. package/dist/worker.d.ts.map +1 -1
  46. package/dist/worker.js +31 -4
  47. package/dist/worker.js.map +1 -1
  48. package/package.json +2 -2
  49. package/src/agent-artifacts.ts +34 -1
  50. package/src/agent-transcript.ts +21 -4
  51. package/src/authored-worker-step.ts +10 -1
  52. package/src/cli/cloud-run.ts +7 -2
  53. package/src/cli-commands.ts +5 -1
  54. package/src/cli.ts +26 -8
  55. package/src/cloud-run.ts +16 -0
  56. package/src/local-agent-pins.ts +7 -0
  57. package/src/local-agent.ts +8 -4
  58. package/src/observer-link.ts +7 -7
  59. package/src/worker-cli.ts +34 -4
  60. package/src/worker-input.ts +14 -2
  61. package/src/worker.ts +30 -4
package/src/cli.ts CHANGED
@@ -48,6 +48,7 @@ import { runCloudLogsFollow, runCloudStatusWatch } from './cli/cloud-live.js';
48
48
  import { transcriptTailSource } from './transcript-tail.js';
49
49
  import { checkTypeScriptFlow } from './cli/check-typescript.js';
50
50
  import { runCloudCli } from './cli/cloud-run.js';
51
+ import { isCloudRunBudgetMinutes } from './cloud-run.js';
51
52
  import { runCloudSyncCli } from './cli/cloud-sync.js';
52
53
  import { parseCloudDeployArgs, runCloudDeployCli, runCloudDeploymentsCli, runCloudUndeployCli, type CloudDeployArgs } from './cli/cloud-deploy.js';
53
54
  import { runCloudRollbackCli, runCloudVersionsCli } from './cli/cloud-versions.js';
@@ -100,7 +101,7 @@ export type ParsedArgs =
100
101
  | BuildArgs
101
102
  | DeployArgs
102
103
  | { command: 'serve-webhook'; dataDir: string; port: number; admitted?: readonly string[] }
103
- | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean }
104
+ | { command: 'cloud-run'; value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect: boolean; budgetMinutes: number | undefined }
104
105
  | { command: 'sync'; runId: string; json: boolean; root: string; dryRun: boolean }
105
106
  | CloudDeployArgs
106
107
  | { command: 'deployments'; json: boolean }
@@ -147,8 +148,8 @@ const USAGE = [
147
148
  'flows check [--against-daemon|--no-daemon-check] [--watch] [--json] [--explain-warnings] <flow.ts|flow.yaml|spec.json>',
148
149
  'flows serve-webhook --data-dir <dir> --port <p> [--allow <name>[,<name>]]',
149
150
  'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] [--reuse-from <run-id>] <flow.yaml|spec.json>',
150
- 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.yaml|spec.json>',
151
- 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] <flow.ts> --input <inline-json-or-file>',
151
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.yaml|spec.json>',
152
+ 'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.ts> --input <inline-json-or-file>',
152
153
  'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
153
154
  'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
154
155
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
@@ -253,6 +254,15 @@ export async function runCli(
253
254
  return 2;
254
255
  }
255
256
 
257
+ if (parsed.command === 'run' && !parsed.json && !parsed.noObserverLink
258
+ && !(parsed.cloudMirror || cloudMirrorRequested(process.env))) {
259
+ const link = resolveObserverLinkEnv(process.env);
260
+ if (!link.suppressed && link.workspaceKey === undefined) {
261
+ io.stderr('Observer unavailable: no workspace key configured; set RELAYCAST_WORKSPACE_KEY or configure '
262
+ + '~/.agentworkforce/relay/workspaces.json for an Observer link, or pass --cloud-mirror for the Cloud dashboard');
263
+ }
264
+ }
265
+
256
266
  if ((parsed.command === 'run' || parsed.command === 'resume') && parsed.detach) {
257
267
  const result = await startDetachedRun(args, {
258
268
  ...parsed, ...(options.selfCommand === undefined ? {} : { selfCommand: options.selfCommand }),
@@ -536,9 +546,9 @@ async function checkAuthoredFlowComposed(path: string): Promise<{ report: CheckR
536
546
 
537
547
  /**
538
548
  * Resolve the pending observer-mint into a URL (or nothing), and route any
539
- * mint warning to stderr under a `[observer]` label. Silent-skip (`undefined`
540
- * input) prints nothing at all, so a workspace with no observer configured
541
- * produces no observer output on stderr or stdout.
549
+ * mint warning to stderr under a `[observer]` label. An `undefined` input
550
+ * prints nothing here; any missing-key hint was already emitted while the
551
+ * observer session was configured.
542
552
  */
543
553
  async function observerUrlFrom(
544
554
  mint: Promise<{ observerUrl?: string; warning?: string }> | undefined,
@@ -729,6 +739,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
729
739
  let wait = false;
730
740
  let syncCode = false;
731
741
  let noConnect = false;
742
+ let budgetMinutes: number | undefined;
732
743
  let localAgent = false;
733
744
  let detach = false;
734
745
  let agentCapacity: number | undefined;
@@ -754,6 +765,13 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
754
765
  else noConnect = true;
755
766
  continue;
756
767
  }
768
+ if (argument === '--budget-minutes') {
769
+ const value = args[++index];
770
+ if (command !== 'run' || budgetMinutes !== undefined || value === undefined || !/^[0-9]+$/.test(value)) return undefined;
771
+ budgetMinutes = Number(value);
772
+ if (!isCloudRunBudgetMinutes(budgetMinutes)) return undefined;
773
+ continue;
774
+ }
757
775
  if (argument === '--detach') {
758
776
  if (command === 'check' || detach) return undefined;
759
777
  detach = true;
@@ -865,9 +883,9 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
865
883
  if (detach || allowHumanInfluenced || sawDataDir || !spawn || localAgent || noObserverLink || cloudMirror
866
884
  || reuseFromRunId !== undefined) return undefined;
867
885
  if (sawInput && !isAuthoredFlowPath(positionals[0]!)) return undefined;
868
- return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect };
886
+ return { command: 'cloud-run', value: positionals[0]!, json, wait, input, syncCode, noConnect, budgetMinutes };
869
887
  }
870
- if (wait || syncCode || noConnect) return undefined;
888
+ if (wait || syncCode || noConnect || budgetMinutes !== undefined) return undefined;
871
889
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
872
890
 
873
891
  if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
package/src/cloud-run.ts CHANGED
@@ -17,9 +17,20 @@ import { cloudRunState, isCloudRunActive, type CloudRunState } from './cloud-run
17
17
  import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
18
18
 
19
19
  export type CloudFlowSource = FlowSpec | { path: string };
20
+ export const MIN_CLOUD_RUN_BUDGET_MINUTES = 30;
21
+ export const MAX_CLOUD_RUN_BUDGET_MINUTES = 180;
22
+
23
+ export function isCloudRunBudgetMinutes(value: number): boolean {
24
+ return Number.isSafeInteger(value)
25
+ && value >= MIN_CLOUD_RUN_BUDGET_MINUTES
26
+ && value <= MAX_CLOUD_RUN_BUDGET_MINUTES;
27
+ }
28
+
20
29
  export interface RunInCloudOptions extends CloudConnectionOptions {
21
30
  /** Existing Cloud workspace to run in; provisioning/authorization remains server-side. */
22
31
  workspaceId?: string;
32
+ /** Hosted run deadline in minutes. Applies to this admission only, not a later resume. */
33
+ runBudgetMinutes?: number;
23
34
  /** Exact JSON input. Required at runtime for authored .flow.ts; refused for declarative source. */
24
35
  input?: JsonValue;
25
36
  /**
@@ -252,6 +263,10 @@ export async function runInCloud(
252
263
  ): Promise<CloudRunReceipt> {
253
264
  // Validate locally first: resolving the connection may refresh a stored
254
265
  // login, which posts the refresh token and rotates the credential file.
266
+ if (options.runBudgetMinutes !== undefined && !isCloudRunBudgetMinutes(options.runBudgetMinutes)) {
267
+ throw new CloudFlowError('configuration',
268
+ `runBudgetMinutes must be an integer from ${MIN_CLOUD_RUN_BUDGET_MINUTES} to ${MAX_CLOUD_RUN_BUDGET_MINUTES}.`);
269
+ }
255
270
  const envSecrets = snapshotEnvSecrets(options.envSecrets);
256
271
  const submission = await prepareCloudSubmission(flow, options);
257
272
  const { baseUrl } = await resolveCloudConnection(options);
@@ -281,6 +296,7 @@ export async function runInCloud(
281
296
  result = await cloudFetch('/api/v1/workflows/run', options, { method: 'POST', detail: true, body: JSON.stringify({
282
297
  ...cloudSubmissionBody(submission),
283
298
  ...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
299
+ ...(options.runBudgetMinutes === undefined ? {} : { relayflowV2RunBudgetMinutes: options.runBudgetMinutes }),
284
300
  ...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
285
301
  ...(envSecrets === undefined ? {} : { envSecrets }),
286
302
  }) });
@@ -0,0 +1,7 @@
1
+ /** Surface a timed-out local agent pins so a fresh worker at the clean tree
2
+ * cannot take the continuation. The local worker always holds it. A step
3
+ * declares it only when a deadline can continue. */
4
+ export const LOCAL_AGENT_WORKSPACE = 'local-workspace';
5
+
6
+ /** Revision the local worker reports before a deadline stamps the dirty tree. */
7
+ export const LOCAL_AGENT_REVISION = 'local';
@@ -4,9 +4,10 @@ import type { JournalClient } from './journal-client.js';
4
4
  import { AgentWorker } from './worker.js';
5
5
  import type { KernelRunSpec } from './spec.js';
6
6
  import { communicationInstruction } from './communication/spec.js';
7
+ import { LOCAL_AGENT_REVISION, LOCAL_AGENT_WORKSPACE } from './local-agent-pins.js';
7
8
  import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
8
9
 
9
- /** A local worker for stream-only steps; no workspace recovery is claimed. */
10
+ /** A local worker. It holds `local-workspace` at `local` so a deadline can stamp the dirty tree. */
10
11
  export async function attachLocalAgent(
11
12
  client: JournalClient,
12
13
  dataDir?: string,
@@ -24,7 +25,8 @@ export async function attachLocalAgent(
24
25
  // This worker has consumed no messages: its read offsets are zero, including
25
26
  // named declarative streams. The kernel retains ownership of dispatch pins
26
27
  // and rejects a resume whose required offsets this worker does not hold.
27
- // No worktree revision is invented.
28
+ // `local-workspace` starts at `local`. A deadline stamps that revision from
29
+ // the dirty tree, so a replacement worker still at `local` cannot take it.
28
30
  const stream = requestedStream ?? `local-agent-${randomUUID()}`;
29
31
  const worker = new AgentWorker(client, {
30
32
  workerId: stream,
@@ -33,8 +35,10 @@ export async function attachLocalAgent(
33
35
  // this same number (worker-slots.ts), so they never ask for more.
34
36
  capacity,
35
37
  dataDir, onPtyReady, runRoot, environment,
36
- pins: { workspace: [], streams: [...new Set([stream, ...declaredStreams])]
37
- .map(stream => ({ stream, read_offset: 0 })) },
38
+ pins: {
39
+ workspace: [{ surface: LOCAL_AGENT_WORKSPACE, revision_id: LOCAL_AGENT_REVISION }],
40
+ streams: [...new Set([stream, ...declaredStreams])].map(stream => ({ stream, read_offset: 0 })),
41
+ },
38
42
  });
39
43
  let failure: unknown;
40
44
  // The cause travels with the close, so the flow's next request names it.
@@ -11,10 +11,10 @@
11
11
  * scoped, short-lived observer token on their behalf and hand *that* to the
12
12
  * URL. The workspace admin key never leaves the operator's machine.
13
13
  *
14
- * Everything here is best-effort. A missing workspace key means "no observer
15
- * link, silently" — this is a parity feature relaying v1's convenience, not a
16
- * requirement of a local run. A mint failure emits a labeled diagnostic to
17
- * stderr and continues; a run must never fail because the observer link failed.
14
+ * Everything here is best-effort. A missing workspace key means no observer
15
+ * link; the human-facing CLI explains the alternatives for a plain local run.
16
+ * A mint failure emits a labeled diagnostic to stderr and continues; a run
17
+ * must never fail because the observer link failed.
18
18
  */
19
19
 
20
20
  import { readFileSync } from 'node:fs';
@@ -165,7 +165,7 @@ export interface ResolveObserverLinkEnvOptions {
165
165
  * 2. Active workspace key from `~/.agentworkforce/relay/workspaces.json`
166
166
  * (populated by `agent-relay cloud login` + `agent-relay workspace
167
167
  * set_key`), when readable.
168
- * 3. Nothing -- silent skip, same as an unset primary env var.
168
+ * 3. Nothing -- the caller decides whether its output mode warrants a hint.
169
169
  *
170
170
  * `FLOWS_NO_OBSERVER=1` still suppresses regardless of source.
171
171
  */
@@ -189,7 +189,7 @@ export function resolveObserverLinkEnv(
189
189
  /**
190
190
  * Read the observer-link config from the environment. Both trims and
191
191
  * emptiness-checks the workspace key so a `RELAYCAST_WORKSPACE_KEY=` with no
192
- * value reads the same as unset (no observer link, silently).
192
+ * value reads the same as unset (no observer link).
193
193
  */
194
194
  export function readObserverLinkEnv(
195
195
  env: NodeJS.ProcessEnv = process.env,
@@ -258,7 +258,7 @@ export interface MintObserverOutcome {
258
258
  /**
259
259
  * A short diagnostic to log when mint failed. `undefined` on success or on
260
260
  * "no observer link is possible" (missing key/suppressed) so the caller can
261
- * distinguish silent-skip from "warn about the miss".
261
+ * distinguish an unavailable link from "warn about a mint failure".
262
262
  */
263
263
  warning?: string;
264
264
  }
package/src/worker-cli.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from 'node:fs';
2
2
  import { basename, dirname, join, resolve, sep } from 'node:path';
3
- import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
3
+ import { deadlineWorkspaceStamp, deletedWorkspaceFiles, diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
4
4
  import { claudeResultOutcome, decodeProviderResult, decodeWrapperResult, requirePricedUsage } from './worker-usage.js';
5
5
  import { openSidechannel, type SidechannelContext } from './pty-sidechannel.js';
6
6
  import {
@@ -87,6 +87,18 @@ export interface WorkerCliResult {
87
87
  * agent runs on another host.
88
88
  */
89
89
  artifacts?: string[];
90
+ /**
91
+ * Content-and-deletion identity of the dirty tree, 16 hex characters.
92
+ * Present when the workspace changed. A deadline appends it to the end-pin
93
+ * revision so two different edits of the same paths, and a deletion with no
94
+ * new file, do not look like the clean start.
95
+ */
96
+ workspace_stamp?: string;
97
+ /**
98
+ * Paths removed during the attempt, cwd-relative and sorted. Journaled
99
+ * beside `artifacts`. Absent when nothing was deleted.
100
+ */
101
+ deleted?: string[];
90
102
  /**
91
103
  * The attempt's transcript digest (`agent-transcript.ts`): provider result
92
104
  * metadata, tool calls summarized, final text, failure excerpt, and — when
@@ -167,9 +179,27 @@ export async function runAgentCli(
167
179
  const before = await snapshotWorkspaceFiles(artifactRoot);
168
180
  const result = await execute();
169
181
  const kernelData = sidechannel === undefined ? undefined : realPath(resolve(sidechannel.dataDir));
170
- const artifacts = diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot))
171
- .filter(path => !under(realPath(resolve(artifactRoot, path)), kernelData));
172
- return { ...result, artifacts };
182
+ const after = await snapshotWorkspaceFiles(artifactRoot);
183
+ const visible = (files: Map<string, string>) => {
184
+ const out = new Map<string, string>();
185
+ for (const [path, signature] of files) {
186
+ // `canonicalTree` keeps a deleted path under the data dir excluded:
187
+ // the file is gone, so a plain realpath falls back to the spelling
188
+ // and can miss a symlinked data dir.
189
+ if (!under(canonicalTree(resolve(artifactRoot, path)), kernelData)) out.set(path, signature);
190
+ }
191
+ return out;
192
+ };
193
+ const stampBefore = visible(before);
194
+ const stampAfter = visible(after);
195
+ const workspace_stamp = deadlineWorkspaceStamp(stampBefore, stampAfter);
196
+ const deleted = deletedWorkspaceFiles(stampBefore, stampAfter);
197
+ return {
198
+ ...result,
199
+ artifacts: diffWorkspaceFiles(stampBefore, stampAfter),
200
+ ...(workspace_stamp === undefined ? {} : { workspace_stamp }),
201
+ ...(deleted.length === 0 ? {} : { deleted }),
202
+ };
173
203
  });
174
204
 
175
205
  async function execute(): Promise<WorkerCliResult> {
@@ -4,12 +4,24 @@ import type { StepDispatchEvent } from './protocol.js';
4
4
  export function workerInstruction(instruction: string, dispatch: StepDispatchEvent): string {
5
5
  const withMemory = dispatch.memory === undefined ? instruction
6
6
  : `${instruction}\n\nMemory context (journaled):\n${JSON.stringify(dispatch.memory.pack)}`;
7
+ const noted = `${withMemory}${recoveryNote(dispatch)}`;
7
8
  if (dispatch.input === undefined) {
8
9
  if (typeof dispatch.spec === 'object' && dispatch.spec !== null
9
10
  && 'input' in dispatch.spec) {
10
11
  throw new Error('Step declares input bindings but the kernel did not resolve them; upgrade relayflowd.');
11
12
  }
12
- return withMemory;
13
+ return noted;
13
14
  }
14
- return `${withMemory}\n\ninput:\n${JSON.stringify(dispatch.input)}`;
15
+ return `${noted}\n\ninput:\n${JSON.stringify(dispatch.input)}`;
16
+ }
17
+
18
+ /** Absent on the first attempt. Null and undefined are both "the kernel had nothing". */
19
+ function recoveryNote(dispatch: StepDispatchEvent): string {
20
+ const reason = dispatch.recovery?.previous_completion_reason;
21
+ const tail = dispatch.recovery?.trajectory_tail;
22
+ if ((reason === undefined || reason === null) && (tail === undefined || tail === null)) return '';
23
+ return `\n\nPrevious attempt (journaled):\n${JSON.stringify({
24
+ completion_reason: reason ?? null,
25
+ trajectory_tail: tail ?? null,
26
+ })}`;
15
27
  }
package/src/worker.ts CHANGED
@@ -222,12 +222,32 @@ export class AgentWorker extends EventEmitter {
222
222
  ...(usage !== undefined ? { usage } : {}),
223
223
  ...(cost === undefined ? {} : { reported_cost: cost }),
224
224
  started_pins: dispatch.pins,
225
- end_pins: dispatch.pins,
225
+ end_pins: completionReason === 'timeout'
226
+ ? deadlineEndPins(dispatch.pins, result.workspace_stamp)
227
+ : dispatch.pins,
226
228
  },
227
229
  );
228
230
  }
229
231
  }
230
232
 
233
+ /**
234
+ * A deadline's end pin names the dirty tree the next attempt must inspect.
235
+ * The stamp covers file contents and deletions, not the file bytes: the tail
236
+ * is capped and the bytes stay on this worker's disk. An empty diff keeps
237
+ * the start pin.
238
+ */
239
+ function deadlineEndPins(pins: Pins, stamp: string | undefined): Pins {
240
+ const workspace = pins.workspace ?? [];
241
+ if (stamp === undefined || workspace.length === 0) return pins;
242
+ if (workspace.every(pin => pin.revision_id === '')) return pins;
243
+ return {
244
+ ...pins,
245
+ workspace: workspace.map(pin => pin.revision_id === ''
246
+ ? pin
247
+ : { ...pin, revision_id: `${pin.revision_id}+${stamp}` }),
248
+ };
249
+ }
250
+
231
251
  /**
232
252
  * The digest as journaled: the attempt and exit code (dropped from `output` on
233
253
  * the JSON path by design) and the artifact paths folded in, then bounded to
@@ -238,9 +258,15 @@ function transcriptDigest(transcript: TranscriptDigest, attempt: number, result:
238
258
  attempt,
239
259
  exit_code: result.exit_code,
240
260
  ...transcript,
241
- ...(result.artifacts === undefined ? {} : { artifacts: {
242
- count: result.artifacts.length, paths: result.artifacts.slice(0, ARTIFACT_PATHS_MAX),
243
- } }),
261
+ ...(result.artifacts === undefined && (result.deleted === undefined || result.deleted.length === 0) ? {} : {
262
+ artifacts: {
263
+ count: result.artifacts?.length ?? 0,
264
+ paths: (result.artifacts ?? []).slice(0, ARTIFACT_PATHS_MAX),
265
+ ...(result.deleted === undefined || result.deleted.length === 0 ? {} : {
266
+ deleted: result.deleted.slice(0, ARTIFACT_PATHS_MAX),
267
+ }),
268
+ },
269
+ }),
244
270
  });
245
271
  }
246
272