@vzn/vx 0.0.626 → 0.0.628

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.626",
3
+ "version": "0.0.628",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.626",
25
- "@vzn/vx-linux-arm64": "0.0.626",
26
- "@vzn/vx-darwin-x64": "0.0.626",
27
- "@vzn/vx-darwin-arm64": "0.0.626"
24
+ "@vzn/vx-linux-x64": "0.0.628",
25
+ "@vzn/vx-linux-arm64": "0.0.628",
26
+ "@vzn/vx-darwin-x64": "0.0.628",
27
+ "@vzn/vx-darwin-arm64": "0.0.628"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -103,7 +103,9 @@ import type { CacheKeyInput } from './layer.js'
103
103
  // bytes on disk, the filter since removed, holds the CRLF build (A-60).
104
104
  // v39: an entry saved while a gitlink without `.git` held files the key
105
105
  // never listed sits under the key of that directory empty (A-61).
106
- export const CACHE_VERSION = 'vx-cache-v41'
106
+ // v42: the stored output is both streams in order (orchestrator/output-log.ts);
107
+ // a v41 entry holds stdout alone and replays no stderr.
108
+ export const CACHE_VERSION = 'vx-cache-v42'
107
109
 
108
110
  /**
109
111
  * The digest a key folds for an input gone between its enumeration and its
@@ -147,7 +147,10 @@ export interface CacheEntry {
147
147
  * per hit (0.12 ms each across a warm 1000-project run before this).
148
148
  */
149
149
  outputDirRows?: OutputDirRow[]
150
- /** Captured stdout, always present (may be empty). stderr is not cached. */
150
+ /**
151
+ * The task's output log, always present (may be empty): both streams in
152
+ * the order the run printed them (`orchestrator/output-log.ts`).
153
+ */
151
154
  stdout: string
152
155
  storedAt: string
153
156
  /**
package/src/exec/index.ts CHANGED
@@ -19,6 +19,8 @@ export {
19
19
  type CaptureConfig,
20
20
  execWord,
21
21
  maskCaptured,
22
+ BoundedCapture,
23
+ type CapturedChunk,
22
24
  } from './runner.js'
23
25
  export {
24
26
  initSandbox,
@@ -1125,56 +1125,73 @@ const DROPPED_LINE = /^\n\[vx\] [\d.]+ MiB of output not kept — /
1125
1125
 
1126
1126
  const isHighSurrogate = (code: number): boolean => code >= 0xd800 && code <= 0xdbff
1127
1127
 
1128
+ /** One piece of a task's output and the stream it came on. */
1129
+ export interface CapturedChunk {
1130
+ text: string
1131
+ /** True when the task wrote it to stderr. */
1132
+ err: boolean
1133
+ }
1134
+
1128
1135
  /**
1129
1136
  * A head-and-tail accumulator: the head fills once, the tail is a ring
1130
1137
  * of chunks trimmed from the front, so memory is bounded by the two
1131
- * limits plus one chunk whatever the task prints.
1138
+ * limits plus one chunk whatever the task prints. Each chunk keeps its
1139
+ * stream, so one capture can hold both in the order they arrived.
1132
1140
  */
1133
- class BoundedCapture {
1134
- private head = ''
1135
- private readonly tail: string[] = []
1141
+ export class BoundedCapture {
1142
+ private readonly head: CapturedChunk[] = []
1143
+ private headLen = 0
1144
+ private readonly tail: CapturedChunk[] = []
1136
1145
  private tailLen = 0
1137
1146
  private dropped = 0
1138
1147
 
1139
- push(chunk: string): void {
1148
+ push(text: string, err = false): void {
1140
1149
  // Once the tail has begun the head is closed, even a unit short of its
1141
1150
  // bound (the surrogate case below): a later short chunk fit there and
1142
1151
  // was retained ahead of the chunk before it.
1143
- if (this.tailLen === 0 && this.head.length < CAPTURE_HEAD_CHARS) {
1144
- let room = CAPTURE_HEAD_CHARS - this.head.length
1145
- if (chunk.length <= room) {
1146
- this.head += chunk
1152
+ if (this.tailLen === 0 && this.headLen < CAPTURE_HEAD_CHARS) {
1153
+ let room = CAPTURE_HEAD_CHARS - this.headLen
1154
+ if (text.length <= room) {
1155
+ this.head.push({ text, err })
1156
+ this.headLen += text.length
1147
1157
  return
1148
1158
  }
1149
1159
  // Never between a surrogate pair's halves: the bounds count UTF-16
1150
1160
  // units, and a halved character read U+FFFD in the replay.
1151
- if (isHighSurrogate(chunk.charCodeAt(room - 1))) room--
1152
- this.head += chunk.slice(0, room)
1153
- chunk = chunk.slice(room)
1161
+ if (isHighSurrogate(text.charCodeAt(room - 1))) room--
1162
+ this.head.push({ text: text.slice(0, room), err })
1163
+ this.headLen += room
1164
+ text = text.slice(room)
1154
1165
  }
1155
- this.tail.push(chunk)
1156
- this.tailLen += chunk.length
1166
+ this.tail.push({ text, err })
1167
+ this.tailLen += text.length
1157
1168
  while (this.tailLen > CAPTURE_TAIL_CHARS) {
1158
1169
  const first = this.tail[0]!
1159
1170
  const excess = this.tailLen - CAPTURE_TAIL_CHARS
1160
- if (first.length <= excess) {
1171
+ if (first.text.length <= excess) {
1161
1172
  this.tail.shift()
1162
- this.tailLen -= first.length
1163
- this.dropped += first.length
1173
+ this.tailLen -= first.text.length
1174
+ this.dropped += first.text.length
1164
1175
  } else {
1165
- const cut = isHighSurrogate(first.charCodeAt(excess - 1)) ? excess + 1 : excess
1166
- this.tail[0] = first.slice(cut)
1176
+ const cut = isHighSurrogate(first.text.charCodeAt(excess - 1)) ? excess + 1 : excess
1177
+ this.tail[0] = { text: first.text.slice(cut), err: first.err }
1167
1178
  this.tailLen -= cut
1168
1179
  this.dropped += cut
1169
1180
  }
1170
1181
  }
1171
1182
  }
1172
1183
 
1173
- text(): string {
1174
- const tail = this.tail.join('')
1184
+ /** The kept chunks in arrival order, the dropped middle named on stdout. */
1185
+ chunks(): CapturedChunk[] {
1175
1186
  return this.dropped === 0
1176
- ? this.head + tail
1177
- : this.head + droppedOutputLine(this.dropped) + tail
1187
+ ? [...this.head, ...this.tail]
1188
+ : [...this.head, { text: droppedOutputLine(this.dropped), err: false }, ...this.tail]
1189
+ }
1190
+
1191
+ text(): string {
1192
+ return this.chunks()
1193
+ .map((c) => c.text)
1194
+ .join('')
1178
1195
  }
1179
1196
  }
1180
1197
 
@@ -39,7 +39,9 @@ import {
39
39
  PersistentReadyError,
40
40
  sandboxReads,
41
41
  maskCaptured,
42
+ BoundedCapture,
42
43
  } from '../exec/index.js'
44
+ import { encodeOutputLog } from './output-log.js'
43
45
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
44
46
  import {
45
47
  killGraceMs,
@@ -644,17 +646,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
644
646
  let unkeyed = unkeyedUpstream !== undefined
645
647
  const willSave = willWrite && args.taintedUpstream !== true && !unkeyed
646
648
 
647
- // Retain only what is read back. `cache.save` below is the single consumer
648
- // of `result.stdout`, and it runs only when this task will WRITE an entry;
649
- // `result.stderr` has no consumer at all (a failing task's stderr reaches
650
- // the user through the live `onStderr` callback, and the cache has never
651
- // stored stderr — see the v17 artifact format). Both streams are still
652
- // drained and still stream chunk-by-chunk to the logger; only the retained
653
- // copy is dropped, which for a chatty task is its full byte size in heap.
649
+ // The executor retains nothing: the entry's output is the attempt's
650
+ // `output` log, both streams in the order they reached the logger, kept
651
+ // only when this task will WRITE an entry. Both streams are still drained
652
+ // and still stream chunk-by-chunk to the logger.
654
653
  // Deferral is decided at plan time and only ever set for a remote-placed,
655
654
  // eligibility-cleared task; it suppresses this machine's clean AND save.
656
655
  const deferralRequested = args.download === 'deferred'
657
- const capture: CaptureConfig = { stdout: willSave, stderr: false }
656
+ const capture: CaptureConfig = { stdout: false, stderr: false }
658
657
 
659
658
  const outputs = cacheCfg?.outputs.files ?? []
660
659
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
@@ -1199,6 +1198,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1199
1198
  // and what the cache keeps of it (L-11); null when there are none.
1200
1199
  const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1201
1200
  let flushMasked = (): void => {}
1201
+ // The attempt's output as the logger got it (masked), for the entry.
1202
+ let output: BoundedCapture | undefined
1203
+ const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
1202
1204
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1203
1205
  // The sampling of the attempt's process tree, stopped once it settles.
1204
1206
  let untrack: (() => void) | undefined
@@ -1288,8 +1290,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1288
1290
  }
1289
1291
 
1290
1292
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1291
- const out = secrets && maskedEmitter(secrets, (t) => log.taskStdout(node, t))
1292
- const err = secrets && maskedEmitter(secrets, (t) => log.taskStderr(node, t))
1293
+ const kept = willSave ? new BoundedCapture() : undefined
1294
+ output = kept
1295
+ const toOut = (t: string): void => {
1296
+ kept?.push(t)
1297
+ log.taskStdout(node, t)
1298
+ }
1299
+ const toErr = (t: string): void => {
1300
+ kept?.push(t, true)
1301
+ log.taskStderr(node, t)
1302
+ }
1303
+ const out = secrets && maskedEmitter(secrets, toOut)
1304
+ const err = secrets && maskedEmitter(secrets, toErr)
1293
1305
  flushMasked = () => {
1294
1306
  out?.end()
1295
1307
  err?.end()
@@ -1303,8 +1315,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1303
1315
  env,
1304
1316
  envDefine: step.env?.define ?? {},
1305
1317
  capture,
1306
- onStdout: out ? (chunk) => out.push(chunk) : (chunk) => log.taskStdout(node, chunk),
1307
- onStderr: err ? (chunk) => err.push(chunk) : (chunk) => log.taskStderr(node, chunk),
1318
+ onStdout: out ? (chunk) => out.push(chunk) : toOut,
1319
+ onStderr: err ? (chunk) => err.push(chunk) : toErr,
1308
1320
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
1309
1321
  ...(args.track !== undefined
1310
1322
  ? {
@@ -1364,7 +1376,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1364
1376
  taskId: node.id,
1365
1377
  command: storedCommand,
1366
1378
  durationMs: result.durationMs,
1367
- stdout: result.stdout,
1379
+ stdout: storedOutput(),
1368
1380
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1369
1381
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1370
1382
  },
@@ -1390,7 +1402,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1390
1402
  captured,
1391
1403
  command: storedCommand,
1392
1404
  durationMs: result.durationMs,
1393
- stdout: result.stdout,
1405
+ stdout: storedOutput(),
1394
1406
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1395
1407
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1396
1408
  outputDirSnapshots: args.outputDirSnapshots,
@@ -30,6 +30,7 @@ import {
30
30
  } from '../util/index.js'
31
31
  import type { ExecuteArgs } from './execute-task.js'
32
32
  import { entryHolds } from './miss-save.js'
33
+ import { decodeOutputLog } from './output-log.js'
33
34
 
34
35
  export interface RestoreHitArgs {
35
36
  args: ExecuteArgs
@@ -45,7 +46,7 @@ export interface RestoreHitArgs {
45
46
  /**
46
47
  * Materialize a confirmed cache hit: decide skip-restore, clean +
47
48
  * restore declared outputs, mark the exact changed paths so downstream
48
- * same-project tasks needn't re-spawn git, replay stored stdout, and
49
+ * same-project tasks needn't re-spawn git, replay stored output, and
49
50
  * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
50
51
  * so the local short-circuit can restore a stable-key hit ahead of the
51
52
  * schedule using the IDENTICAL logic — there is one restore path, not
@@ -97,7 +98,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
97
98
  // path pays one SELECT here. Either beats reading the manifest from
98
99
  // the tar (decompress + parse) at the same point.
99
100
  // A task with no declared outputs has nothing to clean or restore: its
100
- // stdout replays from the row (`hit.stdout`), and its artifact holds
101
+ // output replays from the row (`hit.stdout`), and its artifact holds
101
102
  // only logs. Extracting it anyway cost an `exists` + a tar read per hit
102
103
  // — 1.7 ms each on a four-task real repo whose `link` and `element`
103
104
  // tasks declare `outputs: []` (2026-09-10, VX_TIMING).
@@ -296,7 +297,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
296
297
  [process.env, node.config.exec?.env?.define],
297
298
  node.config.exec?.env?.secret,
298
299
  )
299
- log.taskStdout(node, secrets === null ? hit.stdout : secrets.mask(hit.stdout))
300
+ // The stored log holds both streams in the order the run printed them.
301
+ for (const c of decodeOutputLog(hit.stdout)) {
302
+ const text = secrets === null ? c.text : secrets.mask(c.text)
303
+ if (c.err) log.taskStderr(node, text)
304
+ else log.taskStdout(node, text)
305
+ }
300
306
  }
301
307
  const status =
302
308
  hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
@@ -0,0 +1,53 @@
1
+ // A task's output as a cache entry stores it: both streams in the order
2
+ // vx read them, so a hit replays what the run showed. One string, so every
3
+ // cache layer carries it as it carried stdout: the text as written, with
4
+ // RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
5
+ // a literal RS doubled. Output that never touched stderr is its own text.
6
+
7
+ import type { CapturedChunk } from '../exec/index.js'
8
+
9
+ const RS = '\x1e'
10
+
11
+ /** `chunks` as one stored string; it starts on stdout. */
12
+ export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
13
+ let out = ''
14
+ let err = false
15
+ for (const c of chunks) {
16
+ if (c.text.length === 0) continue
17
+ if (c.err !== err) {
18
+ out += RS + (c.err ? 'e' : 'o')
19
+ err = c.err
20
+ }
21
+ out += c.text.replaceAll(RS, RS + RS)
22
+ }
23
+ return out
24
+ }
25
+
26
+ /** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
27
+ export function decodeOutputLog(log: string): CapturedChunk[] {
28
+ if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
29
+ const chunks: CapturedChunk[] = []
30
+ let err = false
31
+ let text = ''
32
+ const flush = (): void => {
33
+ if (text.length > 0) chunks.push({ text, err })
34
+ text = ''
35
+ }
36
+ let from = 0
37
+ for (let at = log.indexOf(RS); at !== -1; at = log.indexOf(RS, from)) {
38
+ text += log.slice(from, at)
39
+ const next = log[at + 1]
40
+ from = at + 2
41
+ if (next === 'e' || next === 'o') {
42
+ flush()
43
+ err = next === 'e'
44
+ continue
45
+ }
46
+ text += RS
47
+ // A lone RS (at the end, or before any other character) is the text's own.
48
+ if (next !== RS) from = at + 1
49
+ }
50
+ text += log.slice(from)
51
+ flush()
52
+ return chunks
53
+ }