@vzn/vx-reapi 0.0.645 → 0.0.648

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/executor.ts CHANGED
@@ -9,7 +9,17 @@
9
9
  // anything depending on one, and `exec.remote: false` never reach an
10
10
  // executor. This declines the rest of what it cannot honour.
11
11
 
12
- import { mkdir, writeFile, chmod, readlink, realpath, rm, symlink, unlink } from 'node:fs/promises'
12
+ import {
13
+ mkdir,
14
+ writeFile,
15
+ chmod,
16
+ readlink,
17
+ realpath,
18
+ rm,
19
+ statfs,
20
+ symlink,
21
+ unlink,
22
+ } from 'node:fs/promises'
13
23
  import { constants, existsSync } from 'node:fs'
14
24
  import path from 'node:path'
15
25
  import {
@@ -18,6 +28,7 @@ import {
18
28
  isUserError,
19
29
  normalizeGlob,
20
30
  restorableOutput,
31
+ ui,
21
32
  UserError,
22
33
  withForwardArgs,
23
34
  } from '@vzn/vx'
@@ -38,7 +49,14 @@ import {
38
49
  type TreeGraft,
39
50
  } from './merkle.js'
40
51
  import { execDigestFor } from './cache.js'
41
- import type { ActionResult, Digest, Directory, Operation, ReapiClient } from './wire.js'
52
+ import {
53
+ type ActionResult,
54
+ type Digest,
55
+ type Directory,
56
+ MAX_TIMER_MS,
57
+ type Operation,
58
+ type ReapiClient,
59
+ } from './wire.js'
42
60
 
43
61
  // A default decoder strips a leading U+FEFF, which is part of a path.
44
62
  const PATH_UTF8 = new TextDecoder('utf-8', { ignoreBOM: true })
@@ -87,7 +105,7 @@ async function decomposeOutputDir(
87
105
 
88
106
  const blob = await client.readBlob(entry.tree_digest)
89
107
  if (blob === null) {
90
- warn(`vx/reapi: could not read the Tree for ${entry.path} — recording it whole`)
108
+ warn(`[vx] vx-reapi: could not read the Tree for ${entry.path} — recording it whole`)
91
109
  return whole
92
110
  }
93
111
  // Keyed by the WORKER's own bytes, not by re-encoding our parse of them —
@@ -103,16 +121,27 @@ async function decomposeOutputDir(
103
121
  const seen = new Set<string>()
104
122
 
105
123
  // Every transitive child of `dir`, which is what a Tree message must carry.
106
- const descendants = (dir: Directory, acc: Directory[] = []): Directory[] => {
124
+ // Each once: a subtree a Tree names under many names was walked per name,
125
+ // 2^depth from a Tree of depth+1 directories.
126
+ const descendants = (
127
+ dir: Directory,
128
+ acc: Directory[] = [],
129
+ had = new Set<string>(),
130
+ ): Directory[] => {
107
131
  for (const d of dir.directories) {
108
132
  const child = byDigest.get(d.digest.hash)
109
- if (child === undefined) continue
133
+ if (child === undefined || had.has(d.digest.hash)) continue
134
+ had.add(d.digest.hash)
110
135
  acc.push(child)
111
- descendants(child, acc)
136
+ descendants(child, acc, had)
112
137
  }
113
138
  return acc
114
139
  }
115
140
 
141
+ // The whole working directory's capture is `''`: `/name` was recorded
142
+ // under it, and a consumer grafted a directory with an empty name.
143
+ const join = (prefix: string, name: string): string =>
144
+ prefix === '' ? name : `${prefix}/${name}`
116
145
  const walk = (dir: Directory, segments: readonly string[], prefix: string): void => {
117
146
  if (segments.length === 0) {
118
147
  if (seen.has(prefix)) return
@@ -125,13 +154,13 @@ async function decomposeOutputDir(
125
154
  const matches = (name: string): boolean => head === '*' || name === head
126
155
  if (rest.length === 0) {
127
156
  for (const f of dir.files) {
128
- const at = `${prefix}/${f.name}`
157
+ const at = join(prefix, f.name)
129
158
  if (!matches(f.name) || seen.has(at)) continue
130
159
  seen.add(at)
131
160
  files.push({ path: at, digest: f.digest, is_executable: f.is_executable })
132
161
  }
133
162
  for (const sl of dir.symlinks) {
134
- const at = `${prefix}/${sl.name}`
163
+ const at = join(prefix, sl.name)
135
164
  if (!matches(sl.name) || seen.has(at)) continue
136
165
  seen.add(at)
137
166
  symlinks.push({ path: at, target: sl.target })
@@ -141,7 +170,7 @@ async function decomposeOutputDir(
141
170
  if (!matches(d.name)) continue
142
171
  const child = byDigest.get(d.digest.hash)
143
172
  if (child === undefined) continue
144
- walk(child, rest, `${prefix}/${d.name}`)
173
+ walk(child, rest, join(prefix, d.name))
145
174
  }
146
175
  }
147
176
 
@@ -156,7 +185,10 @@ async function decomposeOutputDir(
156
185
  digest: e.tree_digest,
157
186
  data: (e as unknown as { __data: Uint8Array }).__data,
158
187
  }))
159
- await client.uploadBlobs(blobs).catch(async () => {
188
+ await client.uploadBlobs(blobs).catch(async (err: unknown) => {
189
+ warn(
190
+ `[vx] vx-reapi: uploading ${entry.path}'s Tree blobs together failed (${errText(err)}) — writing each`,
191
+ )
160
192
  for (const b of blobs) await client.writeBlob(b.digest, b.data)
161
193
  })
162
194
  return {
@@ -511,21 +543,28 @@ export function acceptsTask(task: TaskPlacement): boolean {
511
543
 
512
544
  export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions = {}): TaskExecutor {
513
545
  // A zero or non-number bound stopped every action the moment it began
514
- // executing.
546
+ // executing, and so did a month's: a timer past MAX_TIMER_MS fires after
547
+ // 1 ms. Core refuses such an exec.timeout, so every bound fits a timer.
515
548
  for (const key of ['executeTimeoutMs', 'queueTimeoutMs'] as const) {
516
549
  const ms = opts[key]
517
- if (ms !== undefined && !(typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
550
+ if (ms !== undefined && !(typeof ms === 'number' && ms > 0 && ms <= MAX_TIMER_MS)) {
518
551
  throw new Error(
519
- `@vzn/vx-reapi: ${key} must be a positive number of ms (got ${JSON.stringify(ms)})`,
552
+ `reapi(): ${key} must be a positive number of ms up to ${MAX_TIMER_MS} (got ${typeof ms === 'number' ? String(ms) : JSON.stringify(ms)})`,
520
553
  )
521
554
  }
522
555
  }
523
- const warn = opts.warn ?? (() => undefined)
556
+ // No listener is no reason to say nothing.
557
+ const warn = opts.warn ?? ((m: string) => ui.say('warn', m))
524
558
  // One Capabilities round trip per executor, not per task — the answer
525
559
  // cannot change mid-run, and a 400-task graph would otherwise ask 400 times.
526
560
  let capsPromise: ReturnType<ReapiClient['capabilities']> | undefined
561
+ // A failed ask is not kept: one call past its deadline failed every later
562
+ // task of the run with the same error.
527
563
  const capabilitiesOnce = (): ReturnType<ReapiClient['capabilities']> =>
528
- (capsPromise ??= client.capabilities())
564
+ (capsPromise ??= client.capabilities().catch((err: unknown) => {
565
+ capsPromise = undefined
566
+ throw err
567
+ }))
529
568
  const executor: TaskExecutor = {
530
569
  name: 'vx/reapi',
531
570
  remote: true,
@@ -542,9 +581,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
542
581
  // status is turned into one by `namedRefusal`), so the scheduler
543
582
  // prints it plainly instead of "internal error in <task>".
544
583
  if (req.inputs === undefined) {
545
- throw new Error(
546
- `vx/reapi: ${req.taskId} reached the remote executor with no described inputs`,
547
- )
584
+ throw new Error(`${req.taskId} reached the remote executor with no described inputs`)
548
585
  }
549
586
 
550
587
  // A key that already has an execution record needs no worker at all:
@@ -574,14 +611,22 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
574
611
  .getActionResult(execDigestFor(req.cacheKey), { stdout: true, files: [] })
575
612
  .catch((err) => {
576
613
  warn(
577
- `vx/reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
614
+ `[vx] vx-reapi: ${req.taskId} could not read its execution record (${errText(err)}) — executing`,
578
615
  )
579
616
  return null
580
617
  })
581
618
  if (prior !== null) {
619
+ // The stdout blob too: a Read of an evicted one answers "none", and
620
+ // the replay printed nothing where the task had printed.
621
+ const stdoutBlob =
622
+ (prior.stdout_raw?.length ?? 0) === 0 &&
623
+ Number(prior.stdout_digest?.size_bytes ?? 0) > 0
624
+ ? [prior.stdout_digest!]
625
+ : []
582
626
  const referenced = [
583
627
  ...(prior.output_files ?? []).map((f) => f.digest),
584
628
  ...(prior.output_directories ?? []).map((d) => d.tree_digest),
629
+ ...stdoutBlob,
585
630
  ]
586
631
  // AC and CAS evict independently, so a record can outlive its
587
632
  // blobs. A gap means it cannot produce the outputs — fall through
@@ -591,7 +636,12 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
591
636
  // and would replay a record whose blobs were never checked.
592
637
  const gone =
593
638
  referenced.length > 0
594
- ? await client.findMissingBlobs(referenced).catch(() => referenced)
639
+ ? await client.findMissingBlobs(referenced).catch((err: unknown) => {
640
+ warn(
641
+ `[vx] vx-reapi: ${req.taskId} could not check its execution record's blobs (${errText(err)}) — executing`,
642
+ )
643
+ return referenced
644
+ })
595
645
  : []
596
646
  // The record's paths are WORKSPACE-relative (rebased when it was
597
647
  // written), so the anchor is the workspace root, not the cwd.
@@ -633,7 +683,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
633
683
  : await replay().catch(async (err: unknown) => {
634
684
  for (const p of created.reverse()) await rm(p, { recursive: true, force: true })
635
685
  warn(
636
- `vx/reapi: ${req.taskId} could not replay its execution record (${errText(err)}) — executing`,
686
+ `[vx] vx-reapi: ${req.taskId} could not replay its execution record (${errText(err)}) — executing`,
637
687
  )
638
688
  return null
639
689
  })
@@ -731,7 +781,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
731
781
  if (present.length > 0) {
732
782
  if (present.length !== up.outputs.length) {
733
783
  warn(
734
- `vx/reapi: ${up.taskId} has ${up.outputs.length - present.length} output(s) recorded ` +
784
+ `[vx] vx-reapi: ${up.taskId} has ${up.outputs.length - present.length} output${up.outputs.length - present.length === 1 ? '' : 's'} recorded ` +
735
785
  `but missing on disk — grafting is not possible for a partial set, using what is here`,
736
786
  )
737
787
  }
@@ -756,7 +806,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
756
806
  const gone = await client.findMissingBlobs(referenced)
757
807
  if (gone.length > 0) {
758
808
  throw new UserError(
759
- `vx/reapi: upstream ${up.taskId} outputs evicted from the remote store (${gone.length} blob(s)) and never materialised locally — re-run it (e.g. --force)`,
809
+ `upstream ${up.taskId} outputs evicted from the remote store (${gone.length} blob${gone.length === 1 ? '' : 's'}) and never materialised locally — re-run it (e.g. --force)`,
760
810
  )
761
811
  }
762
812
  const files = (record.output_files ?? []).map((f) => ({
@@ -780,7 +830,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
780
830
  // real and silently dropping the graft is the same wrong-result
781
831
  // hazard as an evicted file.
782
832
  throw new UserError(
783
- `vx/reapi: upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
833
+ `upstream ${up.taskId} tree ${d.tree_digest.hash.slice(0, 12)} evicted from CAS — re-run it (e.g. --force)`,
784
834
  )
785
835
  }
786
836
  const decodedTree = decodeTreeWithBytes(treeBlob)
@@ -829,12 +879,12 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
829
879
  // next run anywhere replayed the edited outputs (item 1037).
830
880
  if (tree.moved.length > 0) {
831
881
  warn(
832
- `vx/reapi: ${req.taskId}: \`${tree.moved[0]}\` changed after its key was taken — the execution is not recorded under it`,
882
+ `[vx] vx-reapi: ${req.taskId}: \`${tree.moved[0]}\` changed after its key was taken — the execution is not recorded under it`,
833
883
  )
834
884
  }
835
885
  for (const shadowedPath of tree.shadowed) {
836
886
  warn(
837
- `vx/reapi: ${req.taskId} declares input files under ${shadowedPath}, which an upstream graft replaces — those files are NOT in the input tree`,
887
+ `[vx] vx-reapi: ${req.taskId} declares input files under ${shadowedPath}, which an upstream graft replaces — those files are NOT in the input tree`,
838
888
  )
839
889
  }
840
890
 
@@ -871,6 +921,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
871
921
  })
872
922
  const actionDigest = sha256(actionBytes)
873
923
 
924
+ // The action id lets a server group this action's CAS/AC traffic in its
925
+ // UI; named before the upload, which is most of that traffic.
926
+ client.nameAction(actionDigest.hash)
874
927
  const caps = await capabilitiesOnce()
875
928
  const upload: Blob[] = [
876
929
  ...tree.blobs,
@@ -879,9 +932,6 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
879
932
  ]
880
933
  await client.uploadBlobs(upload, caps.maxBatchBytes)
881
934
 
882
- // The action id lets a server group this action's CAS/AC traffic in its
883
- // UI; set before Execute so the streaming call carries it too.
884
- client.actionId = actionDigest.hash
885
935
  // `exec.timeout` rides the Action so the SERVER can enforce it, but a
886
936
  // server that ignores it — or has stopped making progress at all —
887
937
  // leaves the client waiting forever, and the operation stream is no
@@ -917,6 +967,11 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
917
967
  queued.signal,
918
968
  ...(req.signal === undefined ? [] : [req.signal]),
919
969
  ])
970
+ // A cancel the server refused leaves the action running on its worker.
971
+ const cancelFailed = (err: unknown): void =>
972
+ warn(
973
+ `[vx] vx-reapi: cancelling ${req.taskId}'s operation failed (${errText(err)}) — it may still run on its worker`,
974
+ )
920
975
  let op: Operation
921
976
  try {
922
977
  if (req.signal?.aborted === true) throw new Error('aborted before Execute')
@@ -942,7 +997,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
942
997
  // closed stream left the action running on its worker. The action
943
998
  // is cancelled and the task ends as a killed local one does: 143,
944
999
  // which core reports timed out when its timer fired.
945
- if (operation !== '') await client.cancelOperation(operation).catch(() => undefined)
1000
+ if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
946
1001
  return {
947
1002
  exitCode: 143,
948
1003
  durationMs: Math.round((Bun.nanoseconds() - started) / 1e6),
@@ -955,14 +1010,16 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
955
1010
  if (queued.signal.aborted) {
956
1011
  // Best effort: a server without the service, or one that has lost
957
1012
  // the operation, still has the task given back.
958
- if (operation !== '') await client.cancelOperation(operation).catch(() => undefined)
1013
+ if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
959
1014
  throw executorFallback(
960
- `vx/reapi: no worker started the action within queueTimeoutMs (${opts.queueTimeoutMs}ms); its operation was cancelled`,
1015
+ `no worker started the action within queueTimeoutMs (${opts.queueTimeoutMs}ms); its operation was cancelled`,
961
1016
  )
962
1017
  }
963
1018
  if (stall.signal.aborted) {
1019
+ // Closing the stream alone left the action on its worker.
1020
+ if (operation !== '') await client.cancelOperation(operation).catch(cancelFailed)
964
1021
  throw new UserError(
965
- `vx/reapi: ${req.taskId} was still executing ${stallAfter}ms after the worker ` +
1022
+ `${req.taskId} was still executing ${stallAfter}ms after the worker ` +
966
1023
  `started it and the server never reported a result — giving up on the remote ` +
967
1024
  `operation. Raise exec.timeout (or the plugin's executeTimeoutMs) if the task ` +
968
1025
  `is legitimately this slow; otherwise the remote is not making progress.`,
@@ -975,7 +1032,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
975
1032
  }
976
1033
  if (op.error !== undefined && (op.error.code ?? 0) !== 0) {
977
1034
  throw new UserError(
978
- `vx/reapi: execution failed for ${req.taskId}: ${op.error.message ?? `code ${op.error.code}`}`,
1035
+ `execution failed for ${req.taskId}: ${op.error.message ?? `code ${op.error.code}`}`,
979
1036
  )
980
1037
  }
981
1038
  const decoded = decodeExecuteResponse(op)
@@ -988,23 +1045,31 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
988
1045
  // partial result, as the spec suggests: what the command printed
989
1046
  // is delivered before the refusal, best effort (F-48).
990
1047
  if (result !== undefined) {
1048
+ const unread = (stream: string) => (e: unknown) => {
1049
+ warn(`[vx] vx-reapi: ${req.taskId}'s ${stream} could not be read (${errText(e)})`)
1050
+ return ''
1051
+ }
991
1052
  const [out, err] = await Promise.all([
992
- this_readStream(client, result.stdout_raw, result.stdout_digest).catch(() => ''),
993
- this_readStream(client, result.stderr_raw, result.stderr_digest).catch(() => ''),
1053
+ this_readStream(client, result.stdout_raw, result.stdout_digest).catch(
1054
+ unread('stdout'),
1055
+ ),
1056
+ this_readStream(client, result.stderr_raw, result.stderr_digest).catch(
1057
+ unread('stderr'),
1058
+ ),
994
1059
  ])
995
1060
  if (out.length > 0) req.onStdout(out)
996
1061
  if (err.length > 0) req.onStderr(err)
997
1062
  }
998
- const logs = await fetchServerLogs(client, decoded.serverLogs)
1063
+ const logs = await fetchServerLogs(client, decoded.serverLogs, warn)
999
1064
  throw new UserError(
1000
- `vx/reapi: ${req.taskId} execution failed: ${decoded.status.message || `code ${decoded.status.code}`}` +
1065
+ `${req.taskId} execution failed: ${decoded.status.message || `code ${decoded.status.code}`}` +
1001
1066
  (decoded.message === undefined ? '' : ` — ${decoded.message}`) +
1002
1067
  logs,
1003
1068
  )
1004
1069
  }
1005
1070
  if (result === undefined) {
1006
1071
  throw new UserError(
1007
- `vx/reapi: ${req.taskId} returned no ActionResult${decoded.message === undefined ? '' : `: ${decoded.message}`}`,
1072
+ `${req.taskId} returned no ActionResult${decoded.message === undefined ? '' : `: ${decoded.message}`}`,
1008
1073
  )
1009
1074
  }
1010
1075
  // The worker id is REPORTED, not logged: it rides `ExecuteResult.where`
@@ -1031,7 +1096,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1031
1096
  ])
1032
1097
  // Settled below with the outputs; a log that cannot be read still
1033
1098
  // fails the task, after the outputs have landed or failed.
1099
+ // quiet: settled below, where its error fails the task
1034
1100
  logs.catch(() => undefined)
1101
+ // quiet: settled below, where its error fails the task
1035
1102
  materialised.catch(() => undefined)
1036
1103
  const [stdout, stderr] = await logs.catch(async (err: unknown) => {
1037
1104
  await materialised.catch(() => undefined)
@@ -1082,21 +1149,33 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1082
1149
  stdoutDigest = result.stdout_digest
1083
1150
  } else if (stdoutBytes.length > 0 && !inlineStdout) {
1084
1151
  const d = sha256(stdoutBytes)
1085
- const missing = await client.findMissingBlobs([d]).catch(() => [d])
1152
+ const missing = await client.findMissingBlobs([d]).catch((err: unknown) => {
1153
+ warn(
1154
+ `[vx] vx-reapi: ${req.taskId}'s stdout probe failed (${errText(err)}) — uploading it`,
1155
+ )
1156
+ return [d]
1157
+ })
1086
1158
  const uploaded =
1087
1159
  missing.length === 0
1088
1160
  ? true
1089
1161
  : await client.writeBlob(d, stdoutBytes).then(
1090
1162
  () => true,
1091
- () => false,
1163
+ (err: unknown) => {
1164
+ warn(
1165
+ `[vx] vx-reapi: ${req.taskId}'s stdout upload failed (${errText(err)}) — recorded without it`,
1166
+ )
1167
+ return false
1168
+ },
1092
1169
  )
1093
1170
  if (uploaded) stdoutDigest = d
1094
1171
  }
1095
1172
  // A coarse capture is split into the paths its glob really names
1096
1173
  // before it is recorded — see decomposeOutputDir.
1097
1174
  const declaredGlobs = [
1098
- ...req.outputs.workspaceFiles,
1099
- ...req.outputs.files.map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1175
+ ...req.outputs.workspaceFiles.filter(isOutputGlob),
1176
+ ...req.outputs.files
1177
+ .filter(isOutputGlob)
1178
+ .map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1100
1179
  ].map((g) => normalizeGlob(g))
1101
1180
  const recorded: DecomposedOutputs = {
1102
1181
  directories: [],
@@ -1117,7 +1196,7 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1117
1196
  const split = await decomposeOutputDir(client, rebased, declaredGlobs, warn).catch(
1118
1197
  (err: unknown) => {
1119
1198
  warn(
1120
- `vx/reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
1199
+ `[vx] vx-reapi: could not read the Tree for ${rebased.path} (${errText(err)}) — recording it whole`,
1121
1200
  )
1122
1201
  return { directories: [rebased], files: [], symlinks: [] }
1123
1202
  },
@@ -1136,7 +1215,9 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1136
1215
  output_symlinks: recorded.symlinks,
1137
1216
  })
1138
1217
  .catch((err: Error) =>
1139
- warn(`vx/reapi: could not record execution for ${req.taskId}: ${err.message}`),
1218
+ warn(
1219
+ `[vx] vx-reapi: could not record execution for ${req.taskId}: ${err.message}`,
1220
+ ),
1140
1221
  )
1141
1222
  })(req.cacheKey!)
1142
1223
  : Promise.resolve()
@@ -1165,9 +1246,11 @@ export function reapiExecutor(client: ReapiClient, opts: ReapiExecutorOptions =
1165
1246
  }
1166
1247
  const run = executor.execute.bind(executor)
1167
1248
  executor.execute = (req) =>
1168
- run(req).catch((err: unknown) => {
1169
- throw namedRefusal(req.taskId, err)
1170
- })
1249
+ client
1250
+ .inAction(() => run(req))
1251
+ .catch((err: unknown) => {
1252
+ throw namedRefusal(req.taskId, err)
1253
+ })
1171
1254
  return executor
1172
1255
  }
1173
1256
 
@@ -1181,7 +1264,7 @@ function namedRefusal(taskId: string, err: unknown): unknown {
1181
1264
  if (isUserError(err) || !(err instanceof Error)) return err
1182
1265
  const status = err as Error & { code?: unknown; details?: unknown }
1183
1266
  if (typeof status.code !== 'number' || typeof status.details !== 'string') return err
1184
- return new UserError(`vx/reapi: ${taskId}: the remote server failed the call — ${err.message}`)
1267
+ return new UserError(`${taskId}: the remote server failed the call — ${err.message}`)
1185
1268
  }
1186
1269
 
1187
1270
  /**
@@ -1191,12 +1274,16 @@ function namedRefusal(taskId: string, err: unknown): unknown {
1191
1274
  async function fetchServerLogs(
1192
1275
  client: ReapiClient,
1193
1276
  logs: ReadonlyArray<{ name: string; digest: Digest; humanReadable: boolean }>,
1277
+ warn: (m: string) => void,
1194
1278
  ): Promise<string> {
1195
1279
  const readable = logs.filter((l) => l.humanReadable && l.digest.size_bytes <= 64 * 1024)
1196
1280
  if (readable.length === 0) return ''
1197
1281
  const parts: string[] = []
1198
1282
  for (const log of readable) {
1199
- const bytes = await client.readBlob(log.digest).catch(() => null)
1283
+ const bytes = await client.readBlob(log.digest).catch((err: unknown) => {
1284
+ warn(`[vx] vx-reapi: the server log ${log.name} could not be read (${errText(err)})`)
1285
+ return null
1286
+ })
1200
1287
  if (bytes !== null)
1201
1288
  parts.push(`\n--- server log ${log.name} ---\n${new TextDecoder().decode(bytes)}`)
1202
1289
  }
@@ -1249,17 +1336,25 @@ function fullCommand(req: ExecuteRequest, cdInto: string, projectRel: string): s
1249
1336
  .join('/')}`
1250
1337
  const root = cdInto === '' ? `"$PWD${climb}"` : '"$PWD"'
1251
1338
  // Order matters: VX_ROOT is read BEFORE the cd, the project-local bin dir
1252
- // AFTER it, so both are right whichever mode we are in.
1339
+ // AFTER it, so both are right whichever mode we are in. The project's
1340
+ // first, as core's taskBinDirs: a tool the project pins beats the root's.
1253
1341
  // Quoted as the forwarded args are: a project directory is a name like
1254
1342
  // any other, and `it's` ended the quote and ran the rest as script (L-7).
1255
1343
  const cd = cdInto === '' ? '' : `cd '${cdInto.replaceAll("'", `'\\''`)}' || exit 1; `
1256
1344
  return (
1257
1345
  `VX_ROOT=${root}; ${cd}` +
1258
- `export PATH="$VX_ROOT/node_modules/.bin:$PWD/node_modules/.bin:$PATH"; ` +
1346
+ `export PATH="$PWD/node_modules/.bin:$VX_ROOT/node_modules/.bin:$PATH"; ` +
1259
1347
  command
1260
1348
  )
1261
1349
  }
1262
1350
 
1351
+ /**
1352
+ * A `!` take-back names what is NOT an output (core's restore gate reads
1353
+ * it): requested, `!dist/cache/**` was the path `!dist/cache`, and as a
1354
+ * matcher `Bun.Glob('!x')` matches every path but `x`.
1355
+ */
1356
+ const isOutputGlob = (glob: string): boolean => !glob.startsWith('!')
1357
+
1263
1358
  /**
1264
1359
  * vx declares output GLOBS; REAPI `output_paths` are LITERAL paths. The
1265
1360
  * mapping the design doc prescribes: each glob contributes the deepest
@@ -1342,9 +1437,10 @@ export function outputPathSets(
1342
1437
  const prefix = (p: string): string =>
1343
1438
  workingDirectory === '' && projectRel !== '' ? `${projectRel}/${p}` : p
1344
1439
  // `app/\[id\]/x` names the file `app/[id]/x`: `literalHead` unescapes.
1345
- const globs = [...req.outputs.files.map(prefix), ...req.outputs.workspaceFiles.map(rebase)].map(
1346
- (g) => normalizeGlob(g),
1347
- )
1440
+ const globs = [
1441
+ ...req.outputs.files.filter(isOutputGlob).map(prefix),
1442
+ ...req.outputs.workspaceFiles.filter(isOutputGlob).map(rebase),
1443
+ ].map((g) => normalizeGlob(g))
1348
1444
  const paths = new Set<string>()
1349
1445
  const files = new Set<string>()
1350
1446
  const dirs = new Set<string>()
@@ -1402,17 +1498,17 @@ export async function materialiseOutputs(
1402
1498
  const wholeTreeCapture = [
1403
1499
  ...(req.outputs?.files ?? []),
1404
1500
  ...(req.outputs?.workspaceFiles ?? []),
1405
- ].some((g) => globToOutputPath(g) === '')
1501
+ ].some((g) => isOutputGlob(g) && globToOutputPath(g) === '')
1406
1502
  // `abs` names a file a declared glob matches: missing, it is a hole in a
1407
1503
  // declared output under either capture, and warning let `save` cache the
1408
1504
  // short tree under the key (F-8).
1409
1505
  const missing = (what: string, hash: string, abs?: string): void => {
1410
1506
  if (!wholeTreeCapture || req.replay === true || (abs !== undefined && isDeclared(abs))) {
1411
1507
  throw new UserError(
1412
- `vx/reapi: ${req.taskId} declared output ${what} is missing from the CAS (${hash.slice(0, 12)}) — re-run it (e.g. --force)`,
1508
+ `${req.taskId} declared output ${what} is missing from the CAS (${hash.slice(0, 12)}) — re-run it (e.g. --force)`,
1413
1509
  )
1414
1510
  }
1415
- warn(`vx/reapi: output ${what} missing from CAS (${hash.slice(0, 12)})`)
1511
+ warn(`[vx] vx-reapi: output ${what} missing from CAS (${hash.slice(0, 12)})`)
1416
1512
  }
1417
1513
  // A glob cut at its wildcard captured a directory that holds more than
1418
1514
  // the outputs — `src/*.gen.js` returns all of `src`, inputs included.
@@ -1422,8 +1518,10 @@ export async function materialiseOutputs(
1422
1518
  // the task (item 1038). A directory a LITERAL glob names is written whole.
1423
1519
  const projectRel = req.projectRel ?? toPosix(path.relative(req.workspaceRoot, req.cwd))
1424
1520
  const declared = [
1425
- ...(req.outputs?.files ?? []).map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1426
- ...(req.outputs?.workspaceFiles ?? []),
1521
+ ...(req.outputs?.files ?? [])
1522
+ .filter(isOutputGlob)
1523
+ .map((g) => (projectRel === '' ? g : `${projectRel}/${g}`)),
1524
+ ...(req.outputs?.workspaceFiles ?? []).filter(isOutputGlob),
1427
1525
  ].map((g) => normalizeGlob(g))
1428
1526
  const matchers = declared.map((g) => new Bun.Glob(g))
1429
1527
  // Core's restore gate: a `!` take-back, vx's own files and a
@@ -1457,39 +1555,43 @@ export async function materialiseOutputs(
1457
1555
  )
1458
1556
  // Batch the small ones into one round trip; anything larger goes over
1459
1557
  // ByteStream, which is also the only path that can be compressed.
1460
- const small = files.filter((f) => f.digest.size_bytes > 0 && f.digest.size_bytes <= 1024 * 1024)
1461
- const batched = await client.batchReadBlobs(small.map((f) => f.digest))
1462
-
1463
- await mapBounded(files, WRITE_CONCURRENCY, async (f) => {
1464
- const abs = fence.lexical(req.cwd, f.path)
1465
- await fence.dir(path.dirname(abs))
1466
- await makeDir(path.dirname(abs), created)
1467
- await fence.landing(abs)
1468
- // Inlined only when it has bytes: an ActionResult read back through
1469
- // proto-loader (the execution-record replay) carries `contents` as an
1470
- // EMPTY Buffer on every file, and taking that as inline wrote each
1471
- // replayed output empty (item 827). An empty file is the next branch.
1472
- // Inline bytes are held to the digest they ride with, as fetched ones
1473
- // are in wire.ts: a server's wrong inline copy was written as the output
1474
- // and cached under the key (F-8). A mismatch fetches the blob instead.
1475
- const inline =
1476
- f.contents !== undefined &&
1477
- f.contents.length > 0 &&
1478
- digestWith(client.digest, f.contents).hash === f.digest.hash
1479
- const bytes = inline
1480
- ? f.contents! // inlined by the server (`inline_output_files`): zero fetches
1481
- : Number(f.digest.size_bytes) === 0
1482
- ? new Uint8Array()
1483
- : (batched.get(f.digest.hash) ?? (await client.readBlob(f.digest)))
1484
- if (bytes === null) {
1485
- missing(f.path, f.digest.hash, abs)
1486
- return
1487
- }
1488
- await writeOutput(abs, bytes, created)
1489
- // REAPI carries the executable bit per output; a build that produces a
1490
- // script and a later task that runs it depends on it surviving.
1491
- if (f.is_executable === true) await chmod(abs, 0o755)
1492
- })
1558
+ // A window at a time, as a Tree's files are: one batch read of every
1559
+ // small output held them all in memory at once.
1560
+ for (const window of fetchWindows(files, (f) => f.digest)) {
1561
+ const batched = await client.batchReadBlobs(
1562
+ window.filter((f) => isSmallBlob(f.digest)).map((f) => f.digest),
1563
+ )
1564
+ await mapBounded(window, WRITE_CONCURRENCY, async (f) => {
1565
+ const abs = fence.lexical(req.cwd, f.path)
1566
+ await fence.dir(path.dirname(abs))
1567
+ await makeDir(path.dirname(abs), created)
1568
+ await fence.landing(abs)
1569
+ // Inlined only when it has bytes: an ActionResult read back through
1570
+ // proto-loader (the execution-record replay) carries `contents` as an
1571
+ // EMPTY Buffer on every file, and taking that as inline wrote each
1572
+ // replayed output empty (item 827). An empty file is the next branch.
1573
+ // Inline bytes are held to the digest they ride with, as fetched ones
1574
+ // are in wire.ts: a server's wrong inline copy was written as the output
1575
+ // and cached under the key (F-8). A mismatch fetches the blob instead.
1576
+ const inline =
1577
+ f.contents !== undefined &&
1578
+ f.contents.length > 0 &&
1579
+ digestWith(client.digest, f.contents).hash === f.digest.hash
1580
+ const bytes = inline
1581
+ ? f.contents! // inlined by the server (`inline_output_files`): zero fetches
1582
+ : Number(f.digest.size_bytes) === 0
1583
+ ? new Uint8Array()
1584
+ : (batched.get(f.digest.hash) ?? (await client.readBlob(f.digest)))
1585
+ if (bytes === null) {
1586
+ missing(f.path, f.digest.hash, abs)
1587
+ return
1588
+ }
1589
+ await writeOutput(abs, bytes, created)
1590
+ // REAPI carries the executable bit per output; a build that produces a
1591
+ // script and a later task that runs it depends on it surviving.
1592
+ if (f.is_executable === true) await chmod(abs, masked(0o755))
1593
+ })
1594
+ }
1493
1595
 
1494
1596
  // `OutputSymlink` — a declared output that is a link, not a file. Restoring
1495
1597
  // it as a copy would silently change what the next task sees.
@@ -1553,6 +1655,7 @@ class Fence {
1553
1655
  * containment checks passed). The landing must itself be declared.
1554
1656
  */
1555
1657
  async landing(abs: string): Promise<void> {
1658
+ // quiet: a local realpath; the root as given stands
1556
1659
  this.realRoot ??= await realpath(this.root).catch(() => this.root)
1557
1660
  const dir = path.dirname(abs)
1558
1661
  let real = this.realDirs.get(dir)
@@ -1585,9 +1688,11 @@ class Fence {
1585
1688
  /** `dir`, or its deepest existing ancestor, resolves inside the root. */
1586
1689
  async dir(dir: string): Promise<void> {
1587
1690
  if (this.checked.has(dir)) return
1691
+ // quiet: a local realpath; the root as given stands
1588
1692
  this.realRoot ??= await realpath(this.root).catch(() => this.root)
1589
1693
  let probe = dir
1590
1694
  for (;;) {
1695
+ // quiet: a local path not there yet: its parent is checked
1591
1696
  const real = await realpath(probe).catch(() => null)
1592
1697
  if (real !== null) {
1593
1698
  if (real !== this.realRoot && !real.startsWith(this.realRoot + path.sep)) {
@@ -1601,13 +1706,25 @@ class Fence {
1601
1706
  this.checked.add(dir)
1602
1707
  }
1603
1708
 
1604
- /** A link whose target, read from where it stands, stays under the root. */
1709
+ /**
1710
+ * A link whose target, read from where it stands, stays under the root,
1711
+ * placed only where a declared output lands: through an earlier link it
1712
+ * planted a hook or replaced another project's file.
1713
+ */
1605
1714
  async symlink(target: string, abs: string, created: string[] | undefined): Promise<void> {
1606
1715
  const to = path.resolve(path.dirname(abs), target)
1607
1716
  if (target.includes('\0') || (to !== this.root && !to.startsWith(this.root + path.sep))) {
1608
1717
  throw this.refuse(`${abs} -> ${target}`)
1609
1718
  }
1719
+ await this.landing(abs)
1610
1720
  await placeSymlink(target, abs, created)
1721
+ // A link re-placed at `abs` moves every directory under it: a memo
1722
+ // judged through the old one let the next write land through the new.
1723
+ for (const memo of [this.realDirs, this.checked]) {
1724
+ for (const dir of memo.keys()) {
1725
+ if (dir === abs || dir.startsWith(abs + path.sep)) memo.delete(dir)
1726
+ }
1727
+ }
1611
1728
  this.links.push({ abs, target })
1612
1729
  }
1613
1730
 
@@ -1619,6 +1736,7 @@ class Fence {
1619
1736
  * escaping link is removed before the refusal.
1620
1737
  */
1621
1738
  async verifyLinks(): Promise<void> {
1739
+ // quiet: a local realpath; the root as given stands
1622
1740
  this.realRoot ??= await realpath(this.root).catch(() => this.root)
1623
1741
  for (const { abs, target } of this.links) {
1624
1742
  const to = await resolveThrough(
@@ -1633,7 +1751,7 @@ class Fence {
1633
1751
 
1634
1752
  private refuse(what: string): UserError {
1635
1753
  return new UserError(
1636
- `vx/reapi: the server returned an output outside the workspace (${what}) — refused; nothing is written outside ${this.root}`,
1754
+ `the server returned an output outside the workspace (${what}) — refused; nothing is written outside ${this.root}`,
1637
1755
  )
1638
1756
  }
1639
1757
  }
@@ -1694,6 +1812,7 @@ async function resolveThrough(p: string): Promise<string> {
1694
1812
  continue
1695
1813
  }
1696
1814
  const next = path.join(at, c)
1815
+ // quiet: a local path that is no link
1697
1816
  const target = await readlink(next).catch(() => null)
1698
1817
  if (target === null || ++hops > 40) {
1699
1818
  at = next
@@ -1723,7 +1842,7 @@ async function placeSymlink(
1723
1842
  /** Stdout up to this size is recorded inline (`stdout_raw`) rather than as a CAS blob. */
1724
1843
  const INLINE_STDOUT_BYTES = 64 * 1024
1725
1844
 
1726
- /** Small blobs a tree restore holds in memory at once, fetched batched. */
1845
+ /** Small blobs a restore holds in memory at once, fetched batched. */
1727
1846
  const TREE_FETCH_WINDOW_BYTES = 64 * 1024 * 1024
1728
1847
 
1729
1848
  /**
@@ -1763,6 +1882,33 @@ async function materialiseTree(
1763
1882
  const byDigest = new Map<string, Directory>()
1764
1883
  tree.children.forEach((child, i) => byDigest.set(tree.childDigests[i]!, child))
1765
1884
 
1885
+ // A Tree may name one subtree under many names, so 41 directories can
1886
+ // expand to 2^41 paths, and the walk wrote until the disk gave out. Each
1887
+ // path is an inode on disk: counted once per distinct directory, a Tree
1888
+ // that expands past what the file system has free is refused unwritten.
1889
+ const counted = new Map<string, number>()
1890
+ const entries = (dir: Directory): number => {
1891
+ let n = 1 + dir.files.length + dir.symlinks.length
1892
+ for (const d of dir.directories) {
1893
+ const node = byDigest.get(d.digest.hash)
1894
+ if (node === undefined) continue
1895
+ let below = counted.get(d.digest.hash)
1896
+ if (below === undefined) {
1897
+ below = entries(node)
1898
+ counted.set(d.digest.hash, below)
1899
+ }
1900
+ n += below
1901
+ }
1902
+ return n
1903
+ }
1904
+ const expands = entries(tree.root)
1905
+ const room = await freeEntries(destDir)
1906
+ if (expands > room) {
1907
+ throw new UserError(
1908
+ `the server's Tree for ${destDir} expands to ${expands < 1e6 ? expands : expands.toPrecision(2)} entries, more than the ${room} this file system has free — refused`,
1909
+ )
1910
+ }
1911
+
1766
1912
  // Files are gathered from the whole tree before any is fetched: one
1767
1913
  // BatchReadBlobs per DIRECTORY was a round trip each, so a `dist/` of 200
1768
1914
  // directories restored in 200 sequential calls (F-18). Windows bound what
@@ -1795,18 +1941,9 @@ async function materialiseTree(
1795
1941
  }
1796
1942
  await walk(tree.root, destDir, declared === null)
1797
1943
 
1798
- const isSmall = (f: { digest: Digest }): boolean =>
1799
- f.digest.size_bytes > 0 && f.digest.size_bytes <= 1024 * 1024
1800
- for (let i = 0; i < files.length;) {
1801
- let held = 0
1802
- let j = i
1803
- while (j < files.length && (j === i || held < TREE_FETCH_WINDOW_BYTES)) {
1804
- if (isSmall(files[j]!.f)) held += Number(files[j]!.f.digest.size_bytes)
1805
- j++
1806
- }
1807
- const window = files.slice(i, j)
1944
+ for (const window of fetchWindows(files, (w) => w.f.digest)) {
1808
1945
  const batched = await client.batchReadBlobs(
1809
- window.filter((w) => isSmall(w.f)).map((w) => w.f.digest),
1946
+ window.filter((w) => isSmallBlob(w.f.digest)).map((w) => w.f.digest),
1810
1947
  )
1811
1948
  await mapBounded(window, WRITE_CONCURRENCY, async ({ abs, f }) => {
1812
1949
  const bytes =
@@ -1819,14 +1956,57 @@ async function materialiseTree(
1819
1956
  }
1820
1957
  await fence.landing(abs)
1821
1958
  await writeOutput(abs, bytes, created)
1822
- if (f.is_executable) await chmod(abs, 0o755)
1959
+ if (f.is_executable) await chmod(abs, masked(0o755))
1823
1960
  // NodeProperties.unix_mode is authoritative when the server sent it.
1824
1961
  const mode = f.node_properties?.unixMode
1825
1962
  // Permission bits only: a server's setuid or setgid bit is not a build output's.
1826
- if (mode !== undefined) await chmod(abs, mode & 0o777)
1963
+ if (mode !== undefined) await chmod(abs, masked(mode))
1827
1964
  })
1965
+ }
1966
+ }
1967
+
1968
+ /** A blob fetched in a batch read, and so held in memory with its window. */
1969
+ const isSmallBlob = (d: Digest): boolean =>
1970
+ Number(d.size_bytes) > 0 && Number(d.size_bytes) <= 1024 * 1024
1971
+
1972
+ /** `items` cut where their small blobs reach `TREE_FETCH_WINDOW_BYTES`. */
1973
+ function fetchWindows<T>(items: readonly T[], digestOf: (item: T) => Digest): T[][] {
1974
+ const windows: T[][] = []
1975
+ for (let i = 0; i < items.length;) {
1976
+ let held = 0
1977
+ let j = i
1978
+ while (j < items.length && (j === i || held < TREE_FETCH_WINDOW_BYTES)) {
1979
+ const d = digestOf(items[j]!)
1980
+ if (isSmallBlob(d)) held += Number(d.size_bytes)
1981
+ j++
1982
+ }
1983
+ windows.push(items.slice(i, j))
1828
1984
  i = j
1829
1985
  }
1986
+ return windows
1987
+ }
1988
+
1989
+ /**
1990
+ * A mode as this machine gives it: minus the umask, as a local build and
1991
+ * core's restore do. A server's 0777 was written world-writable under
1992
+ * umask 022, and 0755 world-readable under 077.
1993
+ */
1994
+ function masked(mode: number): number {
1995
+ return mode & 0o777 & ~process.umask()
1996
+ }
1997
+
1998
+ /**
1999
+ * The files `dir`'s file system can still create: its free inodes, or its
2000
+ * free blocks where it keeps no inode count (btrfs reports 0 of 0). Asked of
2001
+ * the nearest ancestor that exists; unbounded when none answers.
2002
+ */
2003
+ async function freeEntries(dir: string): Promise<number> {
2004
+ for (let at = dir; ; at = path.dirname(at)) {
2005
+ // quiet: a path not there yet: its parent answers
2006
+ const fs = await statfs(at).catch(() => null)
2007
+ if (fs !== null) return fs.files > 0 ? fs.ffree : fs.bavail
2008
+ if (path.dirname(at) === at) return Infinity
2009
+ }
1830
2010
  }
1831
2011
 
1832
2012
  /** Output files `materialiseOutputs` writes at once. */