@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -31,6 +31,7 @@ import {
31
31
  MAX_SCOPED_PATHSPECS,
32
32
  startGitEnumeration,
33
33
  lazyGitEnumeration,
34
+ measureFsClockLag,
34
35
  } from '../cache/index.js'
35
36
  import {
36
37
  buildPackageGraph,
@@ -50,6 +51,7 @@ import {
50
51
  expandRequested,
51
52
  type GraphRules,
52
53
  isGroupTask,
54
+ refuseFiltersThroughGroups,
53
55
  type TaskNode,
54
56
  undeclaredDepsError,
55
57
  unresolvedRequests,
@@ -61,6 +63,7 @@ import {
61
63
  hasHook,
62
64
  resolveCache,
63
65
  teardownPlugins,
66
+ type ScheduleRun,
64
67
  } from './plugin-host.js'
65
68
  import {
66
69
  discoverProjects,
@@ -83,7 +86,6 @@ import { currentBranch } from './run-context.js'
83
86
  import { createHashCache, type HashCache } from './task-hash.js'
84
87
  import { type Logger, warnOf } from '../report/index.js'
85
88
  import type { RunOptions } from './options.js'
86
- import { refuseFiltersThroughGroups } from './upstream.js'
87
89
  import { runRules } from '../settings.js'
88
90
 
89
91
  export interface PreparedRun {
@@ -114,9 +116,10 @@ export interface PreparedRun {
114
116
  * merged over the structural baseline by the scheduler). Empty when no
115
117
  * plugin declares the stage. Asked only by a run with a task to start:
116
118
  * the stage reads history (148 ms at 8,002 tasks) to order work a
117
- * nothing-changed run never dispatches. Runs the stage once.
119
+ * nothing-changed run never dispatches. Runs the stage once: `run`
120
+ * times each plugin's call and hears its later `ctx.refine` sets.
118
121
  */
119
- priorities: () => Promise<ReadonlyMap<string, number>>
122
+ priorities: (run?: ScheduleRun) => Promise<ReadonlyMap<string, number>>
120
123
  /**
121
124
  * The run's branch (`run.branch`, else git's, else the CI provider's):
122
125
  * what its history records and the timings a forecast reads first.
@@ -256,6 +259,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
256
259
  const cacheDir = options.cacheDir
257
260
  ? path.resolve(options.cwd, options.cacheDir)
258
261
  : resolveCacheDir(workspaceRoot, workspaceConfig)
262
+ // Before any racy window is judged: a file system behind this process's
263
+ // clock dated a mid-command edit before the command (fs-clock.ts).
264
+ measureFsClockLag(cacheDir)
259
265
  // Asked now, read when the cache opens: a repository with no remote is
260
266
  // named by its root commit, a git spawn the discovery below overlaps.
261
267
  const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
@@ -668,7 +674,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
668
674
  let usesWorkspaceInputs = false
669
675
  for (const n of nodes.values()) {
670
676
  graphDirs.add(n.projectDir)
671
- if ((n.config.cache?.inputs.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
677
+ if ((n.config.inputs?.workspaceFiles?.length ?? 0) > 0) usesWorkspaceInputs = true
672
678
  }
673
679
  const projectDirs = [...graphDirs]
674
680
  const enumerating =
@@ -743,7 +749,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
743
749
  return own
744
750
  }
745
751
  let scheduled: Promise<ReadonlyMap<string, number>> | undefined
746
- const priorities = (): Promise<ReadonlyMap<string, number>> =>
752
+ const priorities = (run?: ScheduleRun): Promise<ReadonlyMap<string, number>> =>
747
753
  (scheduled ??= hasHook(plugins, 'schedule')
748
754
  ? applyScheduleHooks(
749
755
  plugins,
@@ -756,6 +762,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
756
762
  branch: branch(),
757
763
  },
758
764
  cacheFor,
765
+ run,
759
766
  )
760
767
  : Promise.resolve(new Map()))
761
768
 
@@ -258,7 +258,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
258
258
  // Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
259
259
  // names the root project unless something names it; a named root's
260
260
  // spelling is rewritten to its name once, here, for the closure, the
261
- // graph and `cache.inputs.tasks` alike. With no root project it stays,
261
+ // graph and `inputs.tasks` alike. With no root project it stays,
262
262
  // and the graph's refusal says so.
263
263
  const root = path.resolve(workspaceRoot)
264
264
  const rootName = args.projectMetas.find((m) => path.resolve(m.dir) === root)?.name
@@ -357,10 +357,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
357
357
  */
358
358
  const DEFAULT_BUILD: TaskConfig = Object.freeze({
359
359
  dependsOn: Object.freeze(['^build']),
360
- cache: Object.freeze({
361
- inputs: Object.freeze({ files: Object.freeze(['**']) }),
362
- outputs: Object.freeze({ files: Object.freeze([]) }),
363
- }),
360
+ inputs: Object.freeze({ files: Object.freeze(['**']) }),
364
361
  })
365
362
 
366
363
  /** The default `build`, which a menu of the tasks a user declared leaves out. */
@@ -374,7 +371,7 @@ function withDefaultBuild(config: ProjectConfig): ProjectConfig {
374
371
  }
375
372
 
376
373
  /**
377
- * `config` with Turbo's `//#task` (in `dependsOn` and `cache.inputs.tasks`)
374
+ * `config` with Turbo's `//#task` (in `dependsOn` and `inputs.tasks`)
378
375
  * spelled `<root>#task`; the same object when it holds none. A copy, not
379
376
  * an edit: a loaded config may be a cached or a frozen one.
380
377
  */
@@ -386,14 +383,14 @@ function rootSpelled(config: ProjectConfig, rootName: string): ProjectConfig {
386
383
  let tasks: NonNullable<ProjectConfig['tasks']> | undefined
387
384
  for (const [name, task] of Object.entries(config.tasks ?? {})) {
388
385
  const dependsOn = fix(task.dependsOn)
389
- const inputTasks = fix(task.cache?.inputs?.tasks)
390
- if (dependsOn === task.dependsOn && inputTasks === task.cache?.inputs?.tasks) continue
386
+ const inputTasks = fix(task.inputs?.tasks)
387
+ if (dependsOn === task.dependsOn && inputTasks === task.inputs?.tasks) continue
391
388
  tasks ??= { ...config.tasks }
392
389
  tasks[name] = {
393
390
  ...task,
394
391
  ...(dependsOn !== undefined ? { dependsOn: [...dependsOn] } : {}),
395
- ...(task.cache?.inputs !== undefined && inputTasks !== undefined
396
- ? { cache: { ...task.cache, inputs: { ...task.cache.inputs, tasks: [...inputTasks] } } }
392
+ ...(task.inputs !== undefined && inputTasks !== undefined
393
+ ? { inputs: { ...task.inputs, tasks: [...inputTasks] } }
397
394
  : {}),
398
395
  }
399
396
  }
@@ -1,8 +1,14 @@
1
1
  // The report every plugin's teardown reads, and the run's own bytes kept
2
2
  // in a remote that takes its writes (owner 2026-10-15): a failed task's
3
- // log and the files its outputs left, an uncached task's log.
3
+ // log and the files its outputs left (`cache.upload.failures`), and the
4
+ // log of a task that saved no entry, on every run (M, 2026-10-16: "Always
5
+ // keep"). Each task's bytes leave when it ends, off its path, like its
6
+ // entry's upload (owner 2026-10-16); the run only waits for them where it
7
+ // waits for every upload, and the footer counts them as the task's remote
8
+ // traffic.
4
9
 
5
10
  import {
11
+ type RemoteTraffic,
6
12
  packRunOutputs,
7
13
  resolveOutputs,
8
14
  resolveWorkspaceOutputs,
@@ -14,17 +20,29 @@ import { relPosix } from '../util/index.js'
14
20
  import { decodeOutputLog } from './output-log.js'
15
21
  import type { RunReport, RunReportTask } from './plugin.js'
16
22
 
17
- export interface RunReportArgs {
23
+ /** Stores one run entry; resolves whether a remote kept it. */
24
+ export type KeepRunEntry = (name: string, bytes: Uint8Array) => Promise<boolean>
25
+
26
+ export interface RunKeeperArgs {
18
27
  readonly runId: string
19
- readonly commit: string | null
20
- readonly ciRun: CiContext
21
- readonly ok: boolean
22
28
  readonly workspaceRoot: string
23
29
  readonly nestedDirsByProject: ReadonlyMap<string, string[]>
24
- /** Stores one run entry; undefined when no remote takes the run's writes. */
25
- readonly keep: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
30
+ readonly keep: KeepRunEntry
31
+ /** Whether a failed task's log and outputs are kept (`cache.upload.failures`). */
32
+ readonly failures: boolean
33
+ /** The footer's remote traffic: each kept task counts under its id. */
34
+ readonly traffic: RemoteTraffic
35
+ }
36
+
37
+ /** Whether a run on CI or off it keeps failures, by `cache.upload.failures` (default `'ci'`). */
38
+ export function keepsFailures(where: unknown, ci: boolean): boolean {
39
+ const at = where ?? 'ci'
40
+ return at === 'always' || (at === 'ci') === ci
26
41
  }
27
42
 
43
+ /** Concurrent run-entry writes; more queue. */
44
+ const KEEP_CONCURRENCY = 8
45
+
28
46
  /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
29
47
  async function outputsLeft(
30
48
  node: TaskNode,
@@ -40,52 +58,133 @@ async function outputsLeft(
40
58
  return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
41
59
  }
42
60
 
43
- export async function keepRunBytes(
44
- final: readonly TaskOutcome[],
45
- args: RunReportArgs,
46
- ): Promise<RunReport> {
47
- const { keep, runId, workspaceRoot } = args
48
- const tasks: RunReportTask[] = []
49
- const work: (() => Promise<void>)[] = []
50
- for (const o of final) {
51
- if (isGroupTask(o.node)) continue
52
- const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
53
- id: o.node.id,
54
- status: o.status,
55
- durationMs: Math.round(o.durationMs),
56
- ...(o.hash !== undefined ? { key: o.hash } : {}),
61
+ /** Keeps each task's run bytes as it ends, and names them in the run's report. */
62
+ export class RunKeeper {
63
+ private readonly kept = new Map<string, { log?: string; outputs?: string }>()
64
+ private readonly queue: (() => Promise<void>)[] = []
65
+ private readonly pending = new Set<Promise<void>>()
66
+ private active = 0
67
+ private stopped = false
68
+
69
+ constructor(private readonly args: RunKeeperArgs) {}
70
+
71
+ /** A task ended: queue what of it the run keeps. */
72
+ taskDone(o: TaskOutcome): void {
73
+ if (this.stopped || isGroupTask(o.node)) return
74
+ const { runId, workspaceRoot } = this.args
75
+ const id = o.node.id
76
+ const slot = (): { log?: string; outputs?: string } => {
77
+ let s = this.kept.get(id)
78
+ if (s === undefined) this.kept.set(id, (s = {}))
79
+ return s
57
80
  }
58
- tasks.push(task)
59
- if (keep === undefined) continue
60
81
  const runLog = o.runLog
61
82
  if (runLog !== undefined) {
62
- work.push(async () => {
63
- const text = decodeOutputLog(runLog)
64
- .map((c) => c.text)
65
- .join('')
66
- if (text.length === 0) return
67
- const name = runEntryName(runId, o.node.id, 'log')
68
- if (await keep(name, new TextEncoder().encode(text))) task.log = name
83
+ const text = decodeOutputLog(runLog)
84
+ .map((c) => c.text)
85
+ .join('')
86
+ if (text.length > 0) {
87
+ const name = runEntryName(runId, id, 'log')
88
+ const bytes = new TextEncoder().encode(text)
89
+ this.enqueue(id, async () => {
90
+ if (!(await this.args.keep(name, bytes))) return false
91
+ slot().log = name
92
+ return true
93
+ })
94
+ }
95
+ }
96
+ if (this.args.failures && o.status === 'failed' && o.where === undefined) {
97
+ // Read now: a later task may write where this one's outputs lie.
98
+ const packed = outputsLeft(
99
+ o.node,
100
+ workspaceRoot,
101
+ this.args.nestedDirsByProject.get(o.node.projectName) ?? [],
102
+ ).then((paths) => packRunOutputs(workspaceRoot, paths))
103
+ const name = runEntryName(runId, id, 'outputs')
104
+ this.enqueue(id, async () => {
105
+ const bytes = await packed
106
+ if (bytes === null || !(await this.args.keep(name, bytes))) return false
107
+ slot().outputs = name
108
+ return true
69
109
  })
70
110
  }
71
- if (o.status === 'failed' && o.where === undefined) {
72
- work.push(async () => {
73
- const bytes = await packRunOutputs(
74
- workspaceRoot,
75
- await outputsLeft(
76
- o.node,
77
- workspaceRoot,
78
- args.nestedDirsByProject.get(o.node.projectName) ?? [],
79
- ),
80
- )
81
- if (bytes === null) return
82
- const name = runEntryName(runId, o.node.id, 'outputs')
83
- if (await keep(name, bytes)) task.outputs = name
111
+ }
112
+
113
+ /** Resolves once every queued write has settled, or the run was stopped. */
114
+ async settled(): Promise<void> {
115
+ while (!this.stopped && this.pending.size > 0) await Promise.all(this.pending)
116
+ }
117
+
118
+ /** A stopped run starts no more writes and stops waiting on those in flight. */
119
+ stop(): void {
120
+ this.stopped = true
121
+ this.queue.length = 0
122
+ }
123
+
124
+ /** What was kept of each task, by id, for `reportOf`. */
125
+ keptOf(id: string): { log?: string; outputs?: string } | undefined {
126
+ return this.kept.get(id)
127
+ }
128
+
129
+ /** Queue `job`, which writes `id`'s bytes; a write that lands counts as `id`'s upload. */
130
+ private enqueue(id: string, job: () => Promise<boolean>): void {
131
+ let done!: () => void
132
+ const p = new Promise<void>((resolve) => (done = resolve))
133
+ this.pending.add(p)
134
+ const { traffic } = this.args
135
+ let key = traffic.keys.get(id)
136
+ if (key === undefined)
137
+ traffic.keys.set(id, (key = { uploading: 0, landed: false, failures: [] }))
138
+ const counted = key
139
+ counted.uploading++
140
+ traffic.inFlight++
141
+ this.queue.push(async () => {
142
+ try {
143
+ if (!this.stopped && (await job())) counted.landed = true
144
+ } catch {
145
+ // a record, never the run's verdict
146
+ } finally {
147
+ counted.uploading--
148
+ traffic.inFlight--
149
+ if (counted.uploading === 0 && !counted.landed && counted.failures.length === 0)
150
+ traffic.keys.delete(id)
151
+ this.pending.delete(p)
152
+ done()
153
+ }
154
+ })
155
+ this.pump()
156
+ }
157
+
158
+ private pump(): void {
159
+ while (this.active < KEEP_CONCURRENCY && this.queue.length > 0) {
160
+ const job = this.queue.shift()!
161
+ this.active++
162
+ void job().finally(() => {
163
+ this.active--
164
+ this.pump()
84
165
  })
85
166
  }
86
167
  }
87
- for (let i = 0; i < work.length; i += 8) {
88
- await Promise.all(work.slice(i, i + 8).map((w) => w()))
168
+ }
169
+
170
+ /** The report: every task, with the names of what `keeper` kept of it. */
171
+ export function reportOf(
172
+ final: readonly TaskOutcome[],
173
+ run: { runId: string; commit: string | null; ciRun: CiContext; ok: boolean },
174
+ keeper: RunKeeper | undefined,
175
+ ): RunReport {
176
+ const tasks: RunReportTask[] = []
177
+ for (const o of final) {
178
+ if (isGroupTask(o.node)) continue
179
+ const kept = keeper?.keptOf(o.node.id)
180
+ tasks.push({
181
+ id: o.node.id,
182
+ status: o.status,
183
+ durationMs: Math.round(o.durationMs),
184
+ ...(o.hash !== undefined ? { key: o.hash } : {}),
185
+ ...(kept?.log !== undefined ? { log: kept.log } : {}),
186
+ ...(kept?.outputs !== undefined ? { outputs: kept.outputs } : {}),
187
+ })
89
188
  }
90
- return { runId, commit: args.commit, ciRun: args.ciRun, ok: args.ok, tasks }
189
+ return { runId: run.runId, commit: run.commit, ciRun: run.ciRun, ok: run.ok, tasks }
91
190
  }
@@ -0,0 +1,57 @@
1
+ // The steps a run did not wait on (`vx:prefetch`), for `vx last` (M,
2
+ // 2026-10-16): they never show in a run's output. One file per run that
3
+ // had one, `<cacheDir>/steps/<runId>.json`, the newest 50 kept, as
4
+ // run-failures.ts keeps failed output: files, not cache.db, so the index
5
+ // layout stays as it was. A run with none writes nothing.
6
+
7
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
8
+ import path from 'node:path'
9
+ import { isFsRefusal } from '../util/index.js'
10
+
11
+ /** Runs whose background steps are kept; run ids are UUIDv7, so names sort by age. */
12
+ const KEPT = 50
13
+
14
+ /** One of vx's own steps that ran beside the tasks. */
15
+ export interface RunStep {
16
+ id: string
17
+ durationMs: number
18
+ ok: boolean
19
+ }
20
+
21
+ const dirOf = (cacheDir: string): string => path.join(cacheDir, 'steps')
22
+
23
+ /** Keep `steps` for `runId`; a refused write costs only them. */
24
+ export function writeRunSteps(
25
+ cacheDir: string,
26
+ runId: string,
27
+ steps: readonly RunStep[],
28
+ warn: (message: string) => void,
29
+ ): void {
30
+ if (steps.length === 0) return
31
+ try {
32
+ const dir = dirOf(cacheDir)
33
+ mkdirSync(dir, { recursive: true })
34
+ const file = path.join(dir, `${runId}.json`)
35
+ const tmp = `${file}.${process.pid}.tmp`
36
+ writeFileSync(tmp, JSON.stringify(steps))
37
+ renameSync(tmp, file)
38
+ const names = readdirSync(dir)
39
+ .filter((n) => n.endsWith('.json'))
40
+ .sort()
41
+ for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
42
+ } catch (err) {
43
+ if (!isFsRefusal(err)) throw err
44
+ warn(`[vx] background steps not kept: ${err.message} — the verdict above stands`)
45
+ }
46
+ }
47
+
48
+ /** The background steps `runId` kept; none when it had none or they are gone. */
49
+ export function runSteps(cacheDir: string, runId: string): RunStep[] {
50
+ try {
51
+ return JSON.parse(
52
+ readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8'),
53
+ ) as RunStep[]
54
+ } catch {
55
+ return []
56
+ }
57
+ }