@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -8,11 +8,15 @@
8
8
  import os from 'node:os'
9
9
  import path from 'node:path'
10
10
  import {
11
+ closeSync,
11
12
  existsSync,
13
+ fstatSync,
12
14
  lstatSync,
13
15
  mkdtempSync,
16
+ openSync,
14
17
  readdirSync,
15
18
  readFileSync,
19
+ readSync,
16
20
  realpathSync,
17
21
  renameSync,
18
22
  rmSync,
@@ -892,7 +896,12 @@ export function fileIdentity(mode: string, oid: string): string {
892
896
  function parseLsFilesOutput(
893
897
  records: readonly string[],
894
898
  undecodableRecords: ReadonlySet<string>,
895
- debug?: { sizes: readonly number[]; indexed: Map<string, { oid: string; size: number }> },
899
+ debug?: {
900
+ sizes: readonly number[]
901
+ ctimes: readonly number[]
902
+ mtimes: readonly number[]
903
+ indexed: Map<string, { oid: string; size: number; ctime: number; mtime: number }>
904
+ },
896
905
  ): GitLsResult {
897
906
  const files: string[] = []
898
907
  const oids = new Map<string, string>()
@@ -914,7 +923,12 @@ function parseLsFilesOutput(
914
923
  if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
915
924
  oids.set(filePath, fileIdentity(mode, m[3]!))
916
925
  if (debug !== undefined && mode !== '120000') {
917
- debug.indexed.set(filePath, { oid: m[3]!, size: debug.sizes[i]! })
926
+ debug.indexed.set(filePath, {
927
+ oid: m[3]!,
928
+ size: debug.sizes[i]!,
929
+ ctime: debug.ctimes[i]!,
930
+ mtime: debug.mtimes[i]!,
931
+ })
918
932
  }
919
933
  } else if (mode === '160000') {
920
934
  gitlinks.add(filePath)
@@ -940,11 +954,15 @@ function parseLsFilesOutput(
940
954
  function splitLsDebug(run: GitRun): {
941
955
  records: string[]
942
956
  sizes: number[]
957
+ ctimes: number[]
958
+ mtimes: number[]
943
959
  undecodable: Set<string>
944
960
  } {
945
961
  const segs = run.stdout.split('\0')
946
962
  const records: string[] = []
947
963
  const sizes: number[] = []
964
+ const ctimes: number[] = []
965
+ const mtimes: number[] = []
948
966
  const undecodable = new Set<string>()
949
967
  let record = segs[0]!
950
968
  let recordRaw = record
@@ -952,12 +970,22 @@ function splitLsDebug(run: GitRun): {
952
970
  const seg = segs[i]
953
971
  let rest = ''
954
972
  let size = -1
973
+ let ctime = -1
974
+ let mtime = -1
955
975
  if (seg !== undefined) {
956
976
  let at = 0
957
977
  for (let k = 0; k < 5 && at >= 0; k++) {
958
978
  const nl = seg.indexOf('\n', at)
959
979
  if (nl < 0) at = -1
960
980
  else {
981
+ // ` ctime: <sec>:<nsec>`, then ` mtime: <sec>:<nsec>`.
982
+ if (k < 2) {
983
+ const n = parseInt(seg.slice(seg.indexOf(':', at) + 1, nl), 10)
984
+ if (!Number.isNaN(n)) {
985
+ if (k === 0) ctime = n
986
+ else mtime = n
987
+ }
988
+ }
961
989
  if (k === 4) {
962
990
  const field = seg.indexOf('size: ', at)
963
991
  if (field >= 0 && field < nl) {
@@ -973,12 +1001,14 @@ function splitLsDebug(run: GitRun): {
973
1001
  if (record.length > 0) {
974
1002
  records.push(record)
975
1003
  sizes.push(size)
1004
+ ctimes.push(ctime)
1005
+ mtimes.push(mtime)
976
1006
  if (run.undecodable.has(recordRaw)) undecodable.add(record)
977
1007
  }
978
1008
  record = rest
979
1009
  recordRaw = seg ?? ''
980
1010
  }
981
- return { records, sizes, undecodable }
1011
+ return { records, sizes, ctimes, mtimes, undecodable }
982
1012
  }
983
1013
 
984
1014
  /**
@@ -998,6 +1028,8 @@ export interface IndexBlobs {
998
1028
  paths: string[]
999
1029
  oids: string[]
1000
1030
  sizes: number[]
1031
+ /** Entries git cannot see a same-second rewrite of (`sameSecond`). */
1032
+ sameSecond: string[]
1001
1033
  }
1002
1034
 
1003
1035
  /** What the blob-size check reads, lazily: the verdict's key, and the listing a miss needs. */
@@ -1010,6 +1042,31 @@ export interface IndexBlobCheck {
1010
1042
  rekey(): Promise<string | undefined>
1011
1043
  }
1012
1044
 
1045
+ /** At most `n` contiguous parts of `items`, none under `CAT_FILE_MIN`: a spawn costs more than a few lookups. */
1046
+ function chunks<T>(items: readonly T[], n: number): T[][] {
1047
+ const parts = Math.min(n, Math.ceil(items.length / CAT_FILE_MIN))
1048
+ const size = Math.ceil(items.length / Math.max(1, parts))
1049
+ const out: T[][] = []
1050
+ for (let at = 0; at < items.length; at += size) out.push(items.slice(at, at + size))
1051
+ return out
1052
+ }
1053
+ const CAT_FILE_MIN = 256
1054
+
1055
+ const sizeChecks = new WeakMap<GitEnumeration, Promise<void>>()
1056
+ /**
1057
+ * `dropResizedOids` once per enumeration: a run starts it as soon as its
1058
+ * cache opens, so the check runs beside the config load and graph build
1059
+ * instead of after them, and `applyGitEnumeration` waits on the same one.
1060
+ */
1061
+ export function checkBlobSizes(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
1062
+ let check = sizeChecks.get(enumeration)
1063
+ if (check === undefined) {
1064
+ check = dropResizedOids(enumeration, memo)
1065
+ sizeChecks.set(enumeration, check)
1066
+ }
1067
+ return check
1068
+ }
1069
+
1013
1070
  /**
1014
1071
  * Drop each trusted OID whose blob is not the size the index recorded for
1015
1072
  * the worktree file (A-60). A clean filter's blob differs from the file it
@@ -1026,6 +1083,8 @@ export interface IndexBlobCheck {
1026
1083
  * (racy) entry records 0 and is hashed from disk. An answer that cannot be
1027
1084
  * read trusts nothing. A filter that keeps the size (`tr a-z A-Z`) is not
1028
1085
  * caught: only a read of every trusted file would be (caching.md, step 12).
1086
+ * The verdict also holds each entry git cannot see a same-second rewrite
1087
+ * of (`sameSecond`), read from the same listing.
1029
1088
  */
1030
1089
  async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
1031
1090
  const { trusted, blobCheck } = enumeration
@@ -1033,31 +1092,47 @@ async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo)
1033
1092
  const key = blobCheck.key
1034
1093
  let resized = key === undefined ? undefined : memo?.blobVerdict(key)
1035
1094
  if (resized === undefined) {
1036
- const blobs = await blobCheck.blobs()
1095
+ // The sizes are asked beside the listing: both read the index the
1096
+ // enumeration listed (the listing refuses one written since), so the
1097
+ // OIDs are the listing's. One after the other, a cold 9,603-task run
1098
+ // waited on the listing's 70-95 ms before the first spawn (2026-11-01).
1099
+ const listing = blobCheck.blobs()
1100
+ // Only what is still trusted: a path dropped already needs no size.
1101
+ // Built here, not with the listing: a run whose verdict is memoized
1102
+ // never needs it, and on 9,603 tasks it cost 17-35 ms (2026-11-01).
1103
+ // A non-regular mode rides its value (`100755:<oid>`); cat-file takes the OID.
1104
+ const oids = [...new Set(Array.from(trusted.values(), (v) => v.slice(v.lastIndexOf(':') + 1)))]
1105
+ const sizes = memo?.knownBlobSizes(oids) ?? new Map<string, number>()
1106
+ const unknown = oids.filter((oid) => !sizes.has(oid))
1107
+ // One cat-file per core: a fresh clone's objects are loose, and one
1108
+ // process opened and inflated 2,505 of them in 30 ms before the first
1109
+ // task of a cold 100-package run; four took 14 (2026-11-03).
1110
+ const asked = Promise.all(
1111
+ chunks(unknown, Math.max(1, navigator.hardwareConcurrency)).map((part) =>
1112
+ enumeration.catFile(part.join('\n') + '\n'),
1113
+ ),
1114
+ )
1115
+ const [blobs, runs] = await Promise.all([listing, asked])
1037
1116
  if (blobs === null) {
1038
1117
  trusted.clear()
1039
1118
  return
1040
1119
  }
1041
- const unique = [...new Set(blobs.oids)]
1042
- const sizes = memo?.knownBlobSizes(unique) ?? new Map<string, number>()
1043
- const unknown = unique.filter((oid) => !sizes.has(oid))
1044
- if (unknown.length > 0) {
1045
- const run = await enumeration.catFile(unknown.join('\n') + '\n')
1046
- if (run === null || run.exitCode !== 0) {
1047
- trusted.clear()
1048
- return
1049
- }
1050
- const learned = new Map<string, number>()
1051
- for (const line of run.stdout.split('\n')) {
1120
+ if (runs.some((run) => run === null || run.exitCode !== 0)) {
1121
+ trusted.clear()
1122
+ return
1123
+ }
1124
+ const learned = new Map<string, number>()
1125
+ for (const run of runs) {
1126
+ for (const line of run!.stdout.split('\n')) {
1052
1127
  const sp = line.indexOf(' ')
1053
1128
  const n = Number(line.slice(sp + 1))
1054
1129
  if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
1055
1130
  learned.set(line.slice(0, sp), n)
1056
1131
  }
1057
- for (const [oid, n] of learned) sizes.set(oid, n)
1058
- memo?.rememberBlobSizes(learned)
1059
1132
  }
1060
- resized = []
1133
+ for (const [oid, n] of learned) sizes.set(oid, n)
1134
+ if (learned.size > 0) memo?.rememberBlobSizes(learned)
1135
+ resized = [...blobs.sameSecond]
1061
1136
  for (let i = 0; i < blobs.oids.length; i++) {
1062
1137
  const size = sizes.get(blobs.oids[i]!)
1063
1138
  // The index keeps the low 32 bits of a size.
@@ -1626,6 +1701,19 @@ function hasRacyEntry(index: Uint8Array, mtimeSec: number, oidBytes: number): bo
1626
1701
  return false
1627
1702
  }
1628
1703
 
1704
+ /**
1705
+ * git compares a file's ctime to the second only, and its racy rule reads
1706
+ * mtime alone: a same-size rewrite that keeps (or restores) an older mtime,
1707
+ * made in the second the index recorded the file, keeps every stat git
1708
+ * compares, and `status` calls it clean. An entry whose recorded ctime, not
1709
+ * its mtime, reaches the second its index was written can hide one; git
1710
+ * re-hashes an entry racy by mtime. Its OID is not trusted. The stamps are
1711
+ * git's own (`ls-files --debug`), judged once per index (the verdict).
1712
+ */
1713
+ function sameSecond(ctimeSec: number, mtimeSec: number, writtenSec: number): boolean {
1714
+ return ctimeSec < 0 || (ctimeSec >= writtenSec && mtimeSec < writtenSec)
1715
+ }
1716
+
1629
1717
  /** A private copy a run older than this may still have handed to `status`. */
1630
1718
  const STALE_INDEX_MS = 60_000
1631
1719
 
@@ -1711,7 +1799,7 @@ export async function startGitEnumeration(
1711
1799
  stdin?: string,
1712
1800
  env?: Record<string, string>,
1713
1801
  ): Promise<GitRun | null> => {
1714
- const spawn = <Out extends 'pipe' | Bun.BunFile>(stdout: Out) =>
1802
+ const spawn = <Out extends 'pipe' | number>(stdout: Out) =>
1715
1803
  Bun.spawn({
1716
1804
  // `status` refreshes the index when it can take `index.lock`, so
1717
1805
  // the user's own `git add` or `commit` failed on the lock while a
@@ -1728,23 +1816,30 @@ export async function startGitEnumeration(
1728
1816
  // loading configs while git lists, and a pipe held a 10 MB listing
1729
1817
  // until the thread came back to drain it 64 KiB at a time (~90 ms of
1730
1818
  // a warm 5,000-project run). In a directory of its own, so no link
1731
- // another user planted in a shared temp directory takes the bytes. A
1732
- // temp directory that takes none falls back to the pipe.
1733
- let dir: string | undefined
1819
+ // another user planted in a shared temp directory takes the bytes, and
1820
+ // the directory goes the moment the file is open: a run stopped
1821
+ // mid-listing (a Ctrl-C before the run's signal handlers, or a kill)
1822
+ // runs no `finally`, and each left a `vx-git-*` behind. A temp
1823
+ // directory that takes none falls back to the pipe.
1824
+ let fd: number | undefined
1734
1825
  try {
1735
- dir = mkdtempSync(path.join(os.tmpdir(), 'vx-git-'))
1826
+ const dir = mkdtempSync(path.join(os.tmpdir(), 'vx-git-'))
1827
+ try {
1828
+ fd = openSync(path.join(dir, 'out'), 'w+', 0o600)
1829
+ } finally {
1830
+ rmSync(dir, { recursive: true, force: true })
1831
+ }
1736
1832
  } catch {
1737
- dir = undefined
1833
+ fd = undefined
1738
1834
  }
1739
1835
  try {
1740
1836
  let stdout: Uint8Array
1741
1837
  let stderr: string
1742
1838
  let exitCode: number
1743
- if (dir !== undefined) {
1744
- const out = path.join(dir, 'out')
1745
- const proc = spawn(Bun.file(out))
1839
+ if (fd !== undefined) {
1840
+ const proc = spawn(fd)
1746
1841
  ;[stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited])
1747
- stdout = await Bun.file(out).bytes()
1842
+ stdout = readWhole(fd)
1748
1843
  } else {
1749
1844
  const piped = spawn('pipe')
1750
1845
  ;[stdout, stderr, exitCode] = await Promise.all([
@@ -1758,7 +1853,7 @@ export async function startGitEnumeration(
1758
1853
  } catch {
1759
1854
  return null
1760
1855
  } finally {
1761
- if (dir !== undefined) rmSync(dir, { recursive: true, force: true })
1856
+ if (fd !== undefined) closeSync(fd)
1762
1857
  }
1763
1858
  }
1764
1859
  const startedAtMs = Date.now()
@@ -1834,7 +1929,9 @@ export async function startGitEnumeration(
1834
1929
  bytes,
1835
1930
  hash,
1836
1931
  st,
1837
- key: `${hash}:${pathspecKey}`,
1932
+ // The mtime too: which entries git can see a same-second rewrite
1933
+ // of (`sameSecond`) turns on the second the index was written.
1934
+ key: `${hash}:${pathspecKey}:${Math.floor(st.mtimeMs / 1000)}`,
1838
1935
  stamp: `${st.ino}:${st.ctimeMs}:${st.size}`,
1839
1936
  }
1840
1937
  } catch {
@@ -1863,10 +1960,16 @@ export async function startGitEnumeration(
1863
1960
  index === undefined ? undefined : { GIT_INDEX_FILE: index },
1864
1961
  )
1865
1962
  // An index with no racy entry is read as it is: `status` spawns now.
1963
+ // The copy `status` read, when it read one: its stats are the ones the
1964
+ // dirty set came from, so the blob check lists it too.
1965
+ let statusCopy: string | undefined
1866
1966
  const statusRun =
1867
1967
  read === undefined || indexFile === undefined || !racyIndex(read, facts!.objectFormat)
1868
1968
  ? spawnStatus(undefined)
1869
- : refreshedIndex(indexFile, read, spawnGit).then(spawnStatus)
1969
+ : refreshedIndex(indexFile, read, spawnGit).then((copy) => {
1970
+ statusCopy = copy
1971
+ return spawnStatus(copy)
1972
+ })
1870
1973
  const running = Promise.all([listing, statusRun, varsRun])
1871
1974
  const gitPrefix = facts?.prefix ?? ''
1872
1975
  const gitDir = facts?.commonDir ?? ''
@@ -1888,6 +1991,17 @@ export async function startGitEnumeration(
1888
1991
  // the stamp moves with any write) and is still in place after it: a
1889
1992
  // `git add` between the listing and the read paired one index's verdict
1890
1993
  // with the other's entries. Otherwise this run asks for no verdict.
1994
+ // A verdict read from the copy holds for that copy file only: a copy
1995
+ // written again (a new inode) asks again.
1996
+ const copyKey = (key: string): string | undefined => {
1997
+ if (statusCopy === undefined) return key
1998
+ try {
1999
+ const st = lstatSync(statusCopy)
2000
+ return `${key}:${st.ino}:${st.mtimeMs}`
2001
+ } catch {
2002
+ return undefined
2003
+ }
2004
+ }
1891
2005
  let indexKey: string | undefined
1892
2006
  if (
1893
2007
  read !== undefined &&
@@ -1895,7 +2009,7 @@ export async function startGitEnumeration(
1895
2009
  ) {
1896
2010
  try {
1897
2011
  const st = lstatSync(indexFile!)
1898
- if (`${st.ino}:${st.ctimeMs}:${st.size}` === read.stamp) indexKey = read.key
2012
+ if (`${st.ino}:${st.ctimeMs}:${st.size}` === read.stamp) indexKey = copyKey(read.key)
1899
2013
  } catch {
1900
2014
  // gone: no key
1901
2015
  }
@@ -1997,24 +2111,47 @@ export async function startGitEnumeration(
1997
2111
  blobCheck: {
1998
2112
  key: indexKey,
1999
2113
  async blobs() {
2000
- const run = await spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs])
2114
+ const listed = statusCopy ?? indexFile
2115
+ let writtenSec = 0
2116
+ try {
2117
+ if (listed !== undefined) writtenSec = Math.floor(lstatSync(listed).mtimeMs / 1000)
2118
+ } catch {
2119
+ return null
2120
+ }
2121
+ const run = await spawnGit(
2122
+ ['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs],
2123
+ undefined,
2124
+ statusCopy === undefined ? undefined : { GIT_INDEX_FILE: statusCopy },
2125
+ )
2001
2126
  if (run === null || run.exitCode !== 0) return null
2002
2127
  // Sizes from an index written since the listing judge entries the
2003
2128
  // trusted OIDs did not come from: an add there rewrote a resized
2004
2129
  // entry to one that fits, and the listed OID stood (A-60).
2005
2130
  if (indexStamp() !== stampBefore) return null
2006
2131
  const debug = splitLsDebug(run)
2007
- const indexed = new Map<string, { oid: string; size: number }>()
2008
- parseLsFilesOutput(debug.records, debug.undecodable, { sizes: debug.sizes, indexed })
2009
- const blobs: IndexBlobs = { paths: [], oids: [], sizes: [] }
2010
- for (const [rel, { oid, size }] of indexed) {
2132
+ const indexed = new Map<
2133
+ string,
2134
+ { oid: string; size: number; ctime: number; mtime: number }
2135
+ >()
2136
+ parseLsFilesOutput(debug.records, debug.undecodable, {
2137
+ sizes: debug.sizes,
2138
+ ctimes: debug.ctimes,
2139
+ mtimes: debug.mtimes,
2140
+ indexed,
2141
+ })
2142
+ const blobs: IndexBlobs = { paths: [], oids: [], sizes: [], sameSecond: [] }
2143
+ for (const [rel, { oid, size, ctime, mtime }] of indexed) {
2011
2144
  blobs.paths.push(rel)
2012
2145
  blobs.oids.push(oid)
2013
2146
  blobs.sizes.push(size)
2147
+ if (sameSecond(ctime, mtime, writtenSec)) blobs.sameSecond.push(rel)
2014
2148
  }
2015
2149
  return blobs
2016
2150
  },
2017
- rekey: async () => (await readIndex())?.key,
2151
+ rekey: async () => {
2152
+ const key = (await readIndex())?.key
2153
+ return key === undefined ? undefined : copyKey(key)
2154
+ },
2018
2155
  },
2019
2156
  // A missing blob is an answer ("missing"), never a fetch from a partial
2020
2157
  // clone's promisor remote.
@@ -2083,7 +2220,7 @@ export async function applyGitEnumeration(
2083
2220
  workspaceWide = false,
2084
2221
  memo?: BlobSizeMemo,
2085
2222
  ): Promise<void> {
2086
- await dropResizedOids(enumeration, memo)
2223
+ await checkBlobSizes(enumeration, memo)
2087
2224
  const { all, trusted } = enumeration
2088
2225
  cache.setWorktreeDirty(enumeration.dirty)
2089
2226
  cache.setGitVars(enumeration.vars)
@@ -2218,3 +2355,15 @@ export async function applyGitEnumeration(
2218
2355
  cache.setWorkspaceRoot(workspaceRoot)
2219
2356
  }
2220
2357
  }
2358
+
2359
+ /** The whole of a file held only by `fd`, from its start. */
2360
+ function readWhole(fd: number): Uint8Array {
2361
+ const bytes = new Uint8Array(fstatSync(fd).size)
2362
+ let at = 0
2363
+ while (at < bytes.length) {
2364
+ const n = readSync(fd, bytes, at, bytes.length - at, at)
2365
+ if (n === 0) break
2366
+ at += n
2367
+ }
2368
+ return at === bytes.length ? bytes : bytes.subarray(0, at)
2369
+ }
@@ -0,0 +1,134 @@
1
+ // A run's hits written to the history while the graph still runs, on a
2
+ // thread of their own (history-writer.ts). A warm run's rows were the
3
+ // main thread's last ~65 ms at 8,002 hits, after the graph; here they land
4
+ // while it runs. What does not land (no thread, a refused batch) is
5
+ // handed back and goes in the run's own bundle, as before.
6
+
7
+ import type { RunRecord } from './layer.js'
8
+ import type { HistoryWriterIn, HistoryWriterOut } from './history-writer.js'
9
+
10
+ /**
11
+ * Rows per batch, and the hits a run has before it starts a thread: a
12
+ * run with fewer writes them in its bundle, as cheap as a thread's start.
13
+ */
14
+ const BATCH = 512
15
+
16
+ /** A record of `T` whose row the stream holds. */
17
+ export interface Streamed<T> {
18
+ item: T
19
+ record: RunRecord
20
+ }
21
+
22
+ export class HistoryStream<T> {
23
+ private worker: Worker | undefined
24
+ private buffer: Streamed<T>[] = []
25
+ private readonly sent = new Map<number, Streamed<T>[]>()
26
+ private readonly landed: Streamed<T>[] = []
27
+ private seq = 0
28
+ /** A batch was refused or the thread died: nothing more is sent. */
29
+ private dead = false
30
+ /** The thread died: no batch answers any more. */
31
+ private gone = false
32
+ private readonly refused = new Set<number>()
33
+ private settle: (() => void) | undefined
34
+ private ended: (() => void) | undefined
35
+
36
+ constructor(private readonly files: { index: string; store?: string }) {}
37
+
38
+ add(item: T, record: RunRecord): void {
39
+ this.buffer.push({ item, record })
40
+ if (this.buffer.length >= BATCH && !this.dead) this.send()
41
+ }
42
+
43
+ /**
44
+ * Waits for every batch sent, closes the thread, and returns what landed
45
+ * and what did not (the run's bundle writes those).
46
+ */
47
+ async finish(): Promise<{ landed: Streamed<T>[]; unlanded: Streamed<T>[] }> {
48
+ const w = this.worker
49
+ if (w !== undefined && !this.gone) {
50
+ // Unreferenced, its answers alone left the loop with nothing to
51
+ // wait on: the run ended "awaited can never settle".
52
+ w.ref()
53
+ await new Promise<void>((resolve) => {
54
+ this.settle = resolve
55
+ this.settled()
56
+ })
57
+ if (!this.gone) {
58
+ await new Promise<void>((resolve) => {
59
+ this.ended = resolve
60
+ w.postMessage({ end: true } satisfies HistoryWriterIn)
61
+ })
62
+ }
63
+ }
64
+ w?.terminate()
65
+ const unlanded = [...this.sent.values()].flat().concat(this.buffer)
66
+ this.sent.clear()
67
+ this.buffer = []
68
+ return { landed: this.landed, unlanded }
69
+ }
70
+
71
+ private send(): void {
72
+ const w = (this.worker ??= this.start())
73
+ const batch = this.buffer
74
+ this.buffer = []
75
+ const seq = this.seq++
76
+ this.sent.set(seq, batch)
77
+ // A record's spans are bigint, which JSON cannot carry: as numbers, exact
78
+ // below 2^53 ns (104 days into a run). A replacer function cost 20 ms
79
+ // of 8,002 rows, the copies 4.
80
+ const hits = JSON.stringify(
81
+ batch.map(({ record: r }) => ({
82
+ ...r,
83
+ wallclockStartNs: r.wallclockStartNs === undefined ? undefined : Number(r.wallclockStartNs),
84
+ wallclockEndNs: r.wallclockEndNs === undefined ? undefined : Number(r.wallclockEndNs),
85
+ })),
86
+ )
87
+ w.postMessage({ seq, hits } satisfies HistoryWriterIn)
88
+ }
89
+
90
+ private start(): Worker {
91
+ const w = new Worker(new URL('./history-writer.ts', import.meta.url).href)
92
+ // A run that throws before `finish` must still exit; a batch cut off
93
+ // mid-write rolls back whole.
94
+ w.unref()
95
+ w.onmessage = (e: MessageEvent<HistoryWriterOut>) => {
96
+ const m = e.data
97
+ if ('ended' in m) {
98
+ this.ended?.()
99
+ return
100
+ }
101
+ const batch = this.sent.get(m.seq)
102
+ if (batch === undefined) return
103
+ if (m.error === undefined) {
104
+ this.sent.delete(m.seq)
105
+ for (const b of batch) this.landed.push(b)
106
+ } else {
107
+ // Kept in `sent`, so the bundle writes it; no later batch is sent.
108
+ this.refused.add(m.seq)
109
+ this.dead = true
110
+ }
111
+ this.settled()
112
+ }
113
+ // A thread that cannot start or dies: what it did not answer for is
114
+ // the bundle's.
115
+ w.onerror = (e) => {
116
+ e.preventDefault()
117
+ this.dead = true
118
+ this.gone = true
119
+ this.settled()
120
+ this.ended?.()
121
+ }
122
+ w.postMessage({ open: this.files } satisfies HistoryWriterIn)
123
+ return w
124
+ }
125
+
126
+ /** Resolves `finish`'s wait once every batch sent has answered, or the thread is gone. */
127
+ private settled(): void {
128
+ if (this.settle === undefined) return
129
+ if (!this.gone && [...this.sent.keys()].some((seq) => !this.refused.has(seq))) return
130
+ const s = this.settle
131
+ this.settle = undefined
132
+ s()
133
+ }
134
+ }
@@ -0,0 +1,76 @@
1
+ // The thread that writes a run's hits to the history while the run goes on
2
+ // (history-stream.ts). A connection of its own on the run's cache files,
3
+ // opened as `Cache` opens them; each batch is one IMMEDIATE transaction,
4
+ // answered by its sequence number so the run knows which landed.
5
+ // A separate entry point: `bun build --compile` embeds it beside bin.ts.
6
+
7
+ import { Database } from 'bun:sqlite'
8
+ import type { RunRecord } from './layer.js'
9
+ import { RunHistory } from './run-history.js'
10
+ import { SQLITE_FCNTL_PERSIST_WAL } from './schema.js'
11
+
12
+ export type HistoryWriterIn =
13
+ | { open: { index: string; store?: string } }
14
+ | { seq: number; hits: string }
15
+ | { end: true }
16
+
17
+ export type HistoryWriterOut = { seq: number; error?: string } | { ended: true }
18
+
19
+ declare const self: Worker
20
+
21
+ let db: Database | undefined
22
+ let history: RunHistory | undefined
23
+ let refused: string | undefined
24
+
25
+ function open(files: { index: string; store?: string }): void {
26
+ // The timeout first, as `Cache` sets it: every later pragma waits under it.
27
+ db = new Database(files.index)
28
+ db.exec('PRAGMA busy_timeout = 5000')
29
+ db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
30
+ db.exec('PRAGMA journal_size_limit = 67108864')
31
+ db.exec('PRAGMA synchronous = NORMAL')
32
+ if (files.store !== undefined) {
33
+ db.prepare('ATTACH DATABASE ? AS store').run(files.store)
34
+ db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
35
+ db.exec('PRAGMA store.journal_size_limit = 67108864')
36
+ db.exec('PRAGMA store.synchronous = NORMAL')
37
+ }
38
+ // A streamed hit carries no forward args (history-stream.ts): nothing digests.
39
+ history = new RunHistory(db, () => {
40
+ throw new Error('a streamed hit has no value to digest')
41
+ })
42
+ }
43
+
44
+ self.onmessage = (e: MessageEvent<HistoryWriterIn>) => {
45
+ const m = e.data
46
+ if ('open' in m) {
47
+ try {
48
+ open(m.open)
49
+ } catch (err) {
50
+ refused = err instanceof Error ? err.message : String(err)
51
+ }
52
+ return
53
+ }
54
+ if ('end' in m) {
55
+ try {
56
+ db?.close()
57
+ } catch {
58
+ // The rows are in; a close that fails leaves the WAL to the next opener.
59
+ }
60
+ postMessage({ ended: true } satisfies HistoryWriterOut)
61
+ return
62
+ }
63
+ if (history === undefined) {
64
+ postMessage({ seq: m.seq, error: refused ?? 'not open' } satisfies HistoryWriterOut)
65
+ return
66
+ }
67
+ try {
68
+ history.recordHits(JSON.parse(m.hits) as RunRecord[])
69
+ postMessage({ seq: m.seq } satisfies HistoryWriterOut)
70
+ } catch (err) {
71
+ postMessage({
72
+ seq: m.seq,
73
+ error: err instanceof Error ? err.message : String(err),
74
+ } satisfies HistoryWriterOut)
75
+ }
76
+ }
@@ -18,6 +18,7 @@ export {
18
18
  type CachePolicy,
19
19
  cachesNothing,
20
20
  EXECUTED_RUNS_SQL,
21
+ historyFiles,
21
22
  indexDb,
22
23
  formatCachePolicy,
23
24
  FULL_CACHE_POLICY,
@@ -81,6 +82,7 @@ export {
81
82
  MAX_SCOPED_PATHSPECS,
82
83
  repoRootOf,
83
84
  startGitEnumeration,
85
+ checkBlobSizes,
84
86
  lazyGitEnumeration,
85
87
  type LazyGitEnumeration,
86
88
  type GitEnumeration,
@@ -103,6 +105,7 @@ export {
103
105
  } from './plugin-cache.js'
104
106
  export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
105
107
  export { branchSource } from './run-history.js'
108
+ export { HistoryStream } from './history-stream.js'
106
109
  export {
107
110
  type KeptFailure,
108
111
  keepRunNotes,