@vzn/vx 0.0.85 → 0.0.87

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/README.md CHANGED
@@ -88,7 +88,8 @@ rm -rf packages/*/dist
88
88
  npx vx run build --all # restored from the cache, not rebuilt
89
89
  ```
90
90
 
91
- More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
91
+ Or copy [`examples/basic`](examples/basic), a two-package starter CI
92
+ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
92
93
  [Configure](https://vznjs.github.io/vx/guides/configure/) ·
93
94
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md).
94
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.85",
3
+ "version": "0.0.87",
4
4
  "description": "An open, extensible monorepo task runner.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.85",
21
- "@vzn/vx-linux-arm64": "0.0.85",
22
- "@vzn/vx-darwin-x64": "0.0.85",
23
- "@vzn/vx-darwin-arm64": "0.0.85"
20
+ "@vzn/vx-linux-x64": "0.0.87",
21
+ "@vzn/vx-linux-arm64": "0.0.87",
22
+ "@vzn/vx-darwin-x64": "0.0.87",
23
+ "@vzn/vx-darwin-arm64": "0.0.87"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -29,6 +29,7 @@ import { Database, type SQLQueryBindings } from 'bun:sqlite'
29
29
  import { accessSync, constants, existsSync, mkdirSync, renameSync, writeFileSync } from 'node:fs'
30
30
  import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
31
31
  import path from 'node:path'
32
+ import { createTables } from './schema.js'
32
33
  import {
33
34
  UserError,
34
35
  asTrees,
@@ -623,212 +624,7 @@ export class Cache implements CacheLayer {
623
624
  }
624
625
  }
625
626
 
626
- // Cached config evaluations (workspace/config-cache.ts): the validated
627
- // config as JSON, keyed by everything the evaluation could observe. A
628
- // separate exec so the artifact schema above stays byte-identical.
629
- this.db.exec(`
630
- CREATE TABLE IF NOT EXISTS config_evals (
631
- key TEXT PRIMARY KEY,
632
- json TEXT NOT NULL,
633
- created_at INTEGER NOT NULL
634
- );
635
- `)
636
-
637
- this.db.exec(`
638
- -- The queryable index: command, exit_code, duration, size,
639
- -- timestamps, and stdout, which the <hash>.tar.zst artifact also
640
- -- carries so it survives a remote round trip; a local hit
641
- -- replays it from here.
642
- CREATE TABLE IF NOT EXISTS entries (
643
- hash TEXT PRIMARY KEY,
644
- project TEXT NOT NULL,
645
- task TEXT NOT NULL,
646
- command TEXT NOT NULL,
647
- exit_code INTEGER NOT NULL,
648
- duration_ms INTEGER NOT NULL,
649
- size_bytes INTEGER NOT NULL,
650
- stdout TEXT NOT NULL DEFAULT '',
651
- created_at INTEGER NOT NULL,
652
- accessed_at INTEGER NOT NULL,
653
- -- v26: the producing execution's usage, from the artifact's sidecar.
654
- cpu_ms INTEGER,
655
- peak_rss_bytes INTEGER
656
- );
657
- CREATE TABLE IF NOT EXISTS runs (
658
- id INTEGER PRIMARY KEY AUTOINCREMENT,
659
- -- '' when the outcome derived no cache key (skipped / persistent).
660
- -- Every reader that must not mistake it for a key guards hash != ''.
661
- hash TEXT NOT NULL,
662
- project TEXT NOT NULL,
663
- task TEXT NOT NULL,
664
- status TEXT NOT NULL,
665
- exit_code INTEGER NOT NULL,
666
- duration_ms INTEGER NOT NULL,
667
- forward_args TEXT,
668
- started_at INTEGER NOT NULL,
669
- ended_at INTEGER NOT NULL,
670
- -- v11 analytics columns. Nullable until the runner / orchestrator
671
- -- PRs populate them. Storing them now means we can swap on the
672
- -- producer side without touching the schema again.
673
- run_id TEXT,
674
- cpu_ms INTEGER,
675
- peak_rss_bytes INTEGER,
676
- wallclock_start_ns INTEGER,
677
- wallclock_end_ns INTEGER,
678
- cache_hit INTEGER,
679
- -- v23: attempts a retried task took (>1); NULL for a once-run task.
680
- -- The direct within-run flaky signal.
681
- attempts INTEGER,
682
- -- v25: 1 when the task declared a cache block, 0 when it runs every
683
- -- time by design; NULL on rows older than the column.
684
- cached INTEGER,
685
- -- v27: why a task failed or was skipped, as the run's own footer
686
- -- said it (items 267–270): a skip's root blocker (a task id), vx's
687
- -- own timeout (1), the sandbox's violation count, and why a
688
- -- persistent task never became ready ('timeout' | 'exited' |
689
- -- 'spawn'). NULL where the reason does not apply.
690
- blocked_by TEXT,
691
- timed_out INTEGER,
692
- sandbox_violations INTEGER,
693
- not_ready TEXT
694
- );
695
- -- Two whole-table indexes only, and both APPEND: every row of a run carries the
696
- -- same run_id and a started_at newer than everything before it, so
697
- -- 1,000 inserts touch a handful of leaf pages. The dropped ones did
698
- -- not — the DROPs shed them from existing databases (a schema-meta
699
- -- bump is for stored shapes, and an index is not one):
700
- -- runs_hash (2026-09-03) had no reader; 11.5 → 3.9 ms per 1,000.
701
- -- runs_project (project, task) (2026-09-09) scattered every run's
702
- -- rows over one leaf per pair: the record stage of a 1,000-hit
703
- -- warm run was 57–79 ms with it and 14–19 ms without, at 166k
704
- -- rows, and it bought its readers nothing — the history reader
705
- -- now scans a rowid slice (history.ts), vx why walks
706
- -- started_at newest-first and stops at the first match.
707
- -- runs_ended (2026-09-09) served only the retention DELETE, which
708
- -- prunes on started_at now (a row ends after it starts, so the
709
- -- 30-day window moves by at most one task's duration).
710
- DROP INDEX IF EXISTS runs_hash;
711
- DROP INDEX IF EXISTS runs_project;
712
- DROP INDEX IF EXISTS runs_ended;
713
- CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
714
- CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
715
- -- The one keyed index, PARTIAL over failed rows: a green run's
716
- -- inserts only evaluate its predicate, so the append-only cost above
717
- -- holds, and the flakiness probe after a miss (failure-mode.ts,
718
- -- "did this key ever fail?") reads a handful of leaves instead of
719
- -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
720
- CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
721
- -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
722
- -- skip the content-hash on inputs whose stat hasn't changed
723
- -- since the last run. Pure performance optimization; the stored
724
- -- hash is the exact same one content-hashing would compute now,
725
- -- so the cache key derivation is unchanged.
726
- CREATE TABLE IF NOT EXISTS file_hashes (
727
- path TEXT PRIMARY KEY,
728
- mtime_ms INTEGER NOT NULL,
729
- size_bytes INTEGER NOT NULL,
730
- ctime_ms INTEGER NOT NULL,
731
- ino INTEGER NOT NULL,
732
- content_hash TEXT NOT NULL,
733
- seen_at INTEGER NOT NULL
734
- );
735
- -- v16: per-output-file fingerprints, scoped by the cache entry
736
- -- that produced them. Lets loadOutputFilesBatch(hashes) answer
737
- -- "for entry X, what are its outputs supposed to look like?"
738
- -- with one SELECT, so the orchestrator can stat-and-skip the
739
- -- whole restore when the tree's already current.
740
- --
741
- -- ON DELETE CASCADE keeps these rows in sync with entries:
742
- -- a cache prune that drops an entry sweeps its output rows
743
- -- automatically.
744
- CREATE TABLE IF NOT EXISTS output_files (
745
- entry_hash TEXT NOT NULL,
746
- path TEXT NOT NULL,
747
- size_bytes INTEGER NOT NULL,
748
- mode INTEGER NOT NULL,
749
- mtime_ms INTEGER NOT NULL,
750
- -- v28: the inode and ctime this machine saw after the save or
751
- -- restore that left the file equal to the entry; NULL until then.
752
- ino INTEGER,
753
- ctime_ms INTEGER,
754
- PRIMARY KEY (entry_hash, path),
755
- FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
756
- );
757
- -- Each config's ORDERED import closure (the config first), so a warm
758
- -- load keys it by stat-hashing the list (the file_hashes memo) instead
759
- -- of reading and scanning every file. Machine-local; pruned with
760
- -- config_evals (2026-09-03).
761
- CREATE TABLE IF NOT EXISTS config_closures (
762
- config_path TEXT PRIMARY KEY,
763
- files_json TEXT NOT NULL,
764
- created_at INTEGER NOT NULL
765
- );
766
- -- Every directory under a whole-subtree output glob, with its mtime
767
- -- as of the last save/restore on THIS machine (2026-09-03). On a warm
768
- -- hit, unchanged mtimes prove the output SET is unchanged, replacing
769
- -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
770
- -- ingest writes none, and the first hit after it walks and records.
771
- CREATE TABLE IF NOT EXISTS output_dirs (
772
- entry_hash TEXT NOT NULL,
773
- path TEXT NOT NULL,
774
- mtime_ms INTEGER NOT NULL,
775
- PRIMARY KEY (entry_hash, path),
776
- FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
777
- );
778
- -- v22 (Tier 3): one header row per vx-run invocation. The runs
779
- -- table is per-task; this is the per-invocation record that
780
- -- carries git/CI/host context, the command, tags, and run-level
781
- -- counts so the dashboard never reconstructs a header with a
782
- -- lossy GROUP BY over runs.
783
- CREATE TABLE IF NOT EXISTS invocations (
784
- run_id TEXT PRIMARY KEY,
785
- command TEXT NOT NULL,
786
- requested_tasks TEXT NOT NULL,
787
- cache_policy TEXT NOT NULL,
788
- concurrency INTEGER NOT NULL,
789
- flow TEXT,
790
- started_at INTEGER NOT NULL,
791
- ended_at INTEGER NOT NULL,
792
- total_duration_ms INTEGER NOT NULL,
793
- task_count INTEGER NOT NULL,
794
- failed_count INTEGER NOT NULL,
795
- hit_count INTEGER NOT NULL,
796
- hit_local_count INTEGER NOT NULL,
797
- hit_remote_count INTEGER NOT NULL,
798
- exit_ok INTEGER NOT NULL,
799
- commit_sha TEXT,
800
- branch TEXT,
801
- dirty INTEGER,
802
- ci INTEGER NOT NULL,
803
- ci_provider TEXT,
804
- host TEXT,
805
- os TEXT,
806
- arch TEXT,
807
- vx_version TEXT NOT NULL,
808
- tags TEXT NOT NULL DEFAULT '{}'
809
- );
810
- CREATE INDEX IF NOT EXISTS invocations_started ON invocations(started_at);
811
- CREATE INDEX IF NOT EXISTS invocations_branch ON invocations(branch);
812
- CREATE INDEX IF NOT EXISTS invocations_ci ON invocations(ci);
813
- -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
814
- -- component, keyed by the cache-ENTRY hash it belongs to (NOT a
815
- -- run id). Written inside the entry-save transaction — only on a
816
- -- miss/save, never on a hit — so a warm all-cache-hit run writes
817
- -- nothing here (the warm path does zero extra work). The
818
- -- why-did-this-re-run diff reads two entry hashes (this run's
819
- -- runs.hash and the previous run's) and anti-joins their rows in
820
- -- SQL over (kind,name,hash); a JSON blob would force an app-side
821
- -- parse + compare on every probe. ON DELETE CASCADE keeps these
822
- -- rows in sync with entries (a prune sweeps them automatically).
823
- CREATE TABLE IF NOT EXISTS entry_inputs (
824
- entry_hash TEXT NOT NULL,
825
- kind TEXT NOT NULL,
826
- name TEXT NOT NULL,
827
- hash TEXT NOT NULL,
828
- PRIMARY KEY (entry_hash, kind, name),
829
- FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
830
- );
831
- `)
627
+ createTables(this.db)
832
628
 
833
629
  this.insertEntry = this.db.prepare(`
834
630
  INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, stdout, created_at, accessed_at, cpu_ms, peak_rss_bytes)
@@ -0,0 +1,215 @@
1
+ // The index's tables, one concern: what a cache.db holds. `Cache` opens the
2
+ // file, checks `SCHEMA_VERSION` and creates these; every statement against
3
+ // them lives in the store that owns the table (file-hashes, config-evals,
4
+ // output-index, history) or in cache.ts for `entries` and `entry_inputs`.
5
+
6
+ import type { Database } from 'bun:sqlite'
7
+
8
+ export function createTables(db: Database): void {
9
+ // Cached config evaluations (workspace/config-cache.ts): the validated
10
+ // config as JSON, keyed by everything the evaluation could observe. A
11
+ // separate exec so the artifact schema stays byte-identical.
12
+ db.exec(`
13
+ CREATE TABLE IF NOT EXISTS config_evals (
14
+ key TEXT PRIMARY KEY,
15
+ json TEXT NOT NULL,
16
+ created_at INTEGER NOT NULL
17
+ );
18
+ `)
19
+
20
+ db.exec(`
21
+ -- The queryable index: command, exit_code, duration, size,
22
+ -- timestamps, and stdout, which the <hash>.tar.zst artifact also
23
+ -- carries so it survives a remote round trip; a local hit
24
+ -- replays it from here.
25
+ CREATE TABLE IF NOT EXISTS entries (
26
+ hash TEXT PRIMARY KEY,
27
+ project TEXT NOT NULL,
28
+ task TEXT NOT NULL,
29
+ command TEXT NOT NULL,
30
+ exit_code INTEGER NOT NULL,
31
+ duration_ms INTEGER NOT NULL,
32
+ size_bytes INTEGER NOT NULL,
33
+ stdout TEXT NOT NULL DEFAULT '',
34
+ created_at INTEGER NOT NULL,
35
+ accessed_at INTEGER NOT NULL,
36
+ -- v26: the producing execution's usage, from the artifact's sidecar.
37
+ cpu_ms INTEGER,
38
+ peak_rss_bytes INTEGER
39
+ );
40
+ CREATE TABLE IF NOT EXISTS runs (
41
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
42
+ -- '' when the outcome derived no cache key (skipped / persistent).
43
+ -- Every reader that must not mistake it for a key guards hash != ''.
44
+ hash TEXT NOT NULL,
45
+ project TEXT NOT NULL,
46
+ task TEXT NOT NULL,
47
+ status TEXT NOT NULL,
48
+ exit_code INTEGER NOT NULL,
49
+ duration_ms INTEGER NOT NULL,
50
+ forward_args TEXT,
51
+ started_at INTEGER NOT NULL,
52
+ ended_at INTEGER NOT NULL,
53
+ -- v11 analytics columns. Nullable until the runner / orchestrator
54
+ -- PRs populate them. Storing them now means we can swap on the
55
+ -- producer side without touching the schema again.
56
+ run_id TEXT,
57
+ cpu_ms INTEGER,
58
+ peak_rss_bytes INTEGER,
59
+ wallclock_start_ns INTEGER,
60
+ wallclock_end_ns INTEGER,
61
+ cache_hit INTEGER,
62
+ -- v23: attempts a retried task took (>1); NULL for a once-run task.
63
+ -- The direct within-run flaky signal.
64
+ attempts INTEGER,
65
+ -- v25: 1 when the task declared a cache block, 0 when it runs every
66
+ -- time by design; NULL on rows older than the column.
67
+ cached INTEGER,
68
+ -- v27: why a task failed or was skipped, as the run's own footer
69
+ -- said it (items 267–270): a skip's root blocker (a task id), vx's
70
+ -- own timeout (1), the sandbox's violation count, and why a
71
+ -- persistent task never became ready ('timeout' | 'exited' |
72
+ -- 'spawn'). NULL where the reason does not apply.
73
+ blocked_by TEXT,
74
+ timed_out INTEGER,
75
+ sandbox_violations INTEGER,
76
+ not_ready TEXT
77
+ );
78
+ -- Two whole-table indexes only, and both APPEND: every row of a run carries the
79
+ -- same run_id and a started_at newer than everything before it, so
80
+ -- 1,000 inserts touch a handful of leaf pages. The dropped ones did
81
+ -- not — the DROPs shed them from existing databases (a schema-meta
82
+ -- bump is for stored shapes, and an index is not one):
83
+ -- runs_hash (2026-09-03) had no reader; 11.5 → 3.9 ms per 1,000.
84
+ -- runs_project (project, task) (2026-09-09) scattered every run's
85
+ -- rows over one leaf per pair: the record stage of a 1,000-hit
86
+ -- warm run was 57–79 ms with it and 14–19 ms without, at 166k
87
+ -- rows, and it bought its readers nothing — the history reader
88
+ -- now scans a rowid slice (history.ts), vx why walks
89
+ -- started_at newest-first and stops at the first match.
90
+ -- runs_ended (2026-09-09) served only the retention DELETE, which
91
+ -- prunes on started_at now (a row ends after it starts, so the
92
+ -- 30-day window moves by at most one task's duration).
93
+ DROP INDEX IF EXISTS runs_hash;
94
+ DROP INDEX IF EXISTS runs_project;
95
+ DROP INDEX IF EXISTS runs_ended;
96
+ CREATE INDEX IF NOT EXISTS runs_started_at ON runs(started_at);
97
+ CREATE INDEX IF NOT EXISTS runs_run_id ON runs(run_id);
98
+ -- The one keyed index, PARTIAL over failed rows: a green run's
99
+ -- inserts only evaluate its predicate, so the append-only cost above
100
+ -- holds, and the flakiness probe after a miss (failure-mode.ts,
101
+ -- "did this key ever fail?") reads a handful of leaves instead of
102
+ -- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
103
+ CREATE INDEX IF NOT EXISTS runs_failed ON runs(hash) WHERE status = 'failed';
104
+ -- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
105
+ -- skip the content-hash on inputs whose stat hasn't changed
106
+ -- since the last run. Pure performance optimization; the stored
107
+ -- hash is the exact same one content-hashing would compute now,
108
+ -- so the cache key derivation is unchanged.
109
+ CREATE TABLE IF NOT EXISTS file_hashes (
110
+ path TEXT PRIMARY KEY,
111
+ mtime_ms INTEGER NOT NULL,
112
+ size_bytes INTEGER NOT NULL,
113
+ ctime_ms INTEGER NOT NULL,
114
+ ino INTEGER NOT NULL,
115
+ content_hash TEXT NOT NULL,
116
+ seen_at INTEGER NOT NULL
117
+ );
118
+ -- v16: per-output-file fingerprints, scoped by the cache entry
119
+ -- that produced them. Lets loadOutputFilesBatch(hashes) answer
120
+ -- "for entry X, what are its outputs supposed to look like?"
121
+ -- with one SELECT, so the orchestrator can stat-and-skip the
122
+ -- whole restore when the tree's already current.
123
+ --
124
+ -- ON DELETE CASCADE keeps these rows in sync with entries:
125
+ -- a cache prune that drops an entry sweeps its output rows
126
+ -- automatically.
127
+ CREATE TABLE IF NOT EXISTS output_files (
128
+ entry_hash TEXT NOT NULL,
129
+ path TEXT NOT NULL,
130
+ size_bytes INTEGER NOT NULL,
131
+ mode INTEGER NOT NULL,
132
+ mtime_ms INTEGER NOT NULL,
133
+ -- v28: the inode and ctime this machine saw after the save or
134
+ -- restore that left the file equal to the entry; NULL until then.
135
+ ino INTEGER,
136
+ ctime_ms INTEGER,
137
+ PRIMARY KEY (entry_hash, path),
138
+ FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
139
+ );
140
+ -- Each config's ORDERED import closure (the config first), so a warm
141
+ -- load keys it by stat-hashing the list (the file_hashes memo) instead
142
+ -- of reading and scanning every file. Machine-local; pruned with
143
+ -- config_evals (2026-09-03).
144
+ CREATE TABLE IF NOT EXISTS config_closures (
145
+ config_path TEXT PRIMARY KEY,
146
+ files_json TEXT NOT NULL,
147
+ created_at INTEGER NOT NULL
148
+ );
149
+ -- Every directory under a whole-subtree output glob, with its mtime
150
+ -- as of the last save/restore on THIS machine (2026-09-03). On a warm
151
+ -- hit, unchanged mtimes prove the output SET is unchanged, replacing
152
+ -- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
153
+ -- ingest writes none, and the first hit after it walks and records.
154
+ CREATE TABLE IF NOT EXISTS output_dirs (
155
+ entry_hash TEXT NOT NULL,
156
+ path TEXT NOT NULL,
157
+ mtime_ms INTEGER NOT NULL,
158
+ PRIMARY KEY (entry_hash, path),
159
+ FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
160
+ );
161
+ -- v22 (Tier 3): one header row per vx-run invocation. The runs
162
+ -- table is per-task; this is the per-invocation record that
163
+ -- carries git/CI/host context, the command, tags, and run-level
164
+ -- counts so the dashboard never reconstructs a header with a
165
+ -- lossy GROUP BY over runs.
166
+ CREATE TABLE IF NOT EXISTS invocations (
167
+ run_id TEXT PRIMARY KEY,
168
+ command TEXT NOT NULL,
169
+ requested_tasks TEXT NOT NULL,
170
+ cache_policy TEXT NOT NULL,
171
+ concurrency INTEGER NOT NULL,
172
+ flow TEXT,
173
+ started_at INTEGER NOT NULL,
174
+ ended_at INTEGER NOT NULL,
175
+ total_duration_ms INTEGER NOT NULL,
176
+ task_count INTEGER NOT NULL,
177
+ failed_count INTEGER NOT NULL,
178
+ hit_count INTEGER NOT NULL,
179
+ hit_local_count INTEGER NOT NULL,
180
+ hit_remote_count INTEGER NOT NULL,
181
+ exit_ok INTEGER NOT NULL,
182
+ commit_sha TEXT,
183
+ branch TEXT,
184
+ dirty INTEGER,
185
+ ci INTEGER NOT NULL,
186
+ ci_provider TEXT,
187
+ host TEXT,
188
+ os TEXT,
189
+ arch TEXT,
190
+ vx_version TEXT NOT NULL,
191
+ tags TEXT NOT NULL DEFAULT '{}'
192
+ );
193
+ CREATE INDEX IF NOT EXISTS invocations_started ON invocations(started_at);
194
+ CREATE INDEX IF NOT EXISTS invocations_branch ON invocations(branch);
195
+ CREATE INDEX IF NOT EXISTS invocations_ci ON invocations(ci);
196
+ -- v22 (Tier 3): the input-fingerprint moat. One row per cache-key
197
+ -- component, keyed by the cache-ENTRY hash it belongs to (NOT a
198
+ -- run id). Written inside the entry-save transaction — only on a
199
+ -- miss/save, never on a hit — so a warm all-cache-hit run writes
200
+ -- nothing here (the warm path does zero extra work). The
201
+ -- why-did-this-re-run diff reads two entry hashes (this run's
202
+ -- runs.hash and the previous run's) and anti-joins their rows in
203
+ -- SQL over (kind,name,hash); a JSON blob would force an app-side
204
+ -- parse + compare on every probe. ON DELETE CASCADE keeps these
205
+ -- rows in sync with entries (a prune sweeps them automatically).
206
+ CREATE TABLE IF NOT EXISTS entry_inputs (
207
+ entry_hash TEXT NOT NULL,
208
+ kind TEXT NOT NULL,
209
+ name TEXT NOT NULL,
210
+ hash TEXT NOT NULL,
211
+ PRIMARY KEY (entry_hash, kind, name),
212
+ FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
213
+ );
214
+ `)
215
+ }
package/src/cli/run.ts CHANGED
@@ -585,7 +585,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
585
585
  const plan = await planRun(opts)
586
586
  if (plan.unresolvedTasks !== undefined && plan.unresolvedTasks.length > 0) {
587
587
  process.stderr.write(
588
- `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.\n`,
588
+ `vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}\n`,
589
589
  )
590
590
  return 1
591
591
  }
@@ -142,9 +142,11 @@ export interface ExecuteRequest {
142
142
  /**
143
143
  * Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
144
144
  * `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
145
- * work and returns; core waits for `execute`, and a child an executor
146
- * spawned is not one core can reach (H-10, H-12). A non-zero exit after
147
- * the timeout's abort is recorded `timedOut`.
145
+ * work and returns: a child an executor spawned is not one core can
146
+ * reach (H-10, H-12). A non-zero exit after the timeout's abort is
147
+ * recorded `timedOut`. Core waits the kill grace (`VX_KILL_GRACE_MS`,
148
+ * 2 s) after the abort, then settles the attempt without `execute` and
149
+ * abandons it (H-14).
148
150
  */
149
151
  readonly signal?: AbortSignal
150
152
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
package/src/exec/index.ts CHANGED
@@ -30,7 +30,7 @@ export {
30
30
  export { type DeniedCall } from './sandbox-violations.js'
31
31
  export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
32
32
  export { bindableWrites, punchWalls } from './sandbox-binds.js'
33
- export { localExecutor } from './local-executor.js'
33
+ export { isLocalExecutor, localExecutor } from './local-executor.js'
34
34
  export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
35
35
  export {
36
36
  assertExecuteResult,
@@ -8,9 +8,20 @@ import { runCommand } from './runner.js'
8
8
  import { runSandboxed } from './sandbox-runtime.js'
9
9
  import type { TaskExecutor } from './executor.js'
10
10
 
11
+ const locals = new WeakSet<TaskExecutor>()
12
+
13
+ /**
14
+ * Whether `executor` is core's own. By identity: a plugin may name its
15
+ * executor 'local', and core bounds a plugin's `execute` after an abort,
16
+ * which the local one (it SIGKILLs its own group) never needs.
17
+ */
18
+ export function isLocalExecutor(executor: TaskExecutor): boolean {
19
+ return locals.has(executor)
20
+ }
21
+
11
22
  /** The local executor. Accepts every task. */
12
23
  export function localExecutor(): TaskExecutor {
13
- return {
24
+ const executor: TaskExecutor = {
14
25
  name: 'local',
15
26
  async execute(req) {
16
27
  const common = {
@@ -38,4 +49,6 @@ export function localExecutor(): TaskExecutor {
38
49
  })
39
50
  },
40
51
  }
52
+ locals.add(executor)
53
+ return executor
41
54
  }
@@ -25,7 +25,15 @@
25
25
 
26
26
  import path from 'node:path'
27
27
  import os from 'node:os'
28
- import { mkdirSync, readlinkSync, realpathSync, rmSync, unlinkSync } from 'node:fs'
28
+ import {
29
+ closeSync,
30
+ mkdirSync,
31
+ openSync,
32
+ readlinkSync,
33
+ realpathSync,
34
+ rmSync,
35
+ unlinkSync,
36
+ } from 'node:fs'
29
37
  import { mkdir, unlink } from 'node:fs/promises'
30
38
  import type { SandboxConfig } from '../config.js'
31
39
  import {
@@ -736,6 +744,8 @@ export async function wrapSandboxedCommand(
736
744
  Pick<SandboxedRunArgs, 'baseAllowRead' | 'baseDenyRead'> & {
737
745
  /** A persistent server: the sandbox outlives a run's reset until `releaseBridges(tag)`. */
738
746
  server?: boolean
747
+ /** Trace the command's `openat` calls to descriptor TRACE_FD (Linux; `wantsStraceDetection`). */
748
+ trace?: 'plain' | 'seccomp'
739
749
  },
740
750
  ): Promise<{
741
751
  wrapped: string
@@ -746,6 +756,8 @@ export async function wrapSandboxedCommand(
746
756
  baselines: CanonicalBaselines
747
757
  /** The command reads its polite signals off fd 3: spawn it with one and `signalThrough` it. */
748
758
  forwardsSignals: boolean
759
+ /** The command writes its trace to fd TRACE_FD: spawn it with the log there. */
760
+ traced: boolean
749
761
  }> {
750
762
  const { SandboxManager } = await loadSrt()
751
763
  const userCommand = withForwardArgs(args.command, args.forwardArgs)
@@ -770,8 +782,8 @@ export async function wrapSandboxedCommand(
770
782
  const ports = process.platform === 'linux' ? bridgedPorts(args.config) : []
771
783
  const grouped =
772
784
  process.platform === 'linux'
773
- ? ownGroupCommand(tag, inTmp)
774
- : { command: taggedCommand, forwards: false }
785
+ ? ownGroupCommand(tag, inTmp, args.trace)
786
+ : { command: taggedCommand, forwards: false, traced: false }
775
787
  const inner =
776
788
  ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
777
789
  let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
@@ -783,9 +795,8 @@ export async function wrapSandboxedCommand(
783
795
  // `--die-with-parent` is keyed to vx. Behind a shell that waited on it,
784
796
  // a `kill -9` of vx left the shell alive, bwrap never heard, and a
785
797
  // sandboxed server and all it forked outlived vx (turborepo#9666). Now
786
- // the namespace goes with vx, a `setsid` daemon inside included. A
787
- // strace-traced spawn keeps strace as bwrap's parent: that residual is
788
- // a one-shot task's, and a persistent one is never traced.
798
+ // the namespace goes with vx, a `setsid` daemon inside included, a
799
+ // traced one-shot task too: its strace runs inside (B-11).
789
800
  if (process.platform === 'linux' && wrapped.startsWith('bwrap ')) wrapped = `exec ${wrapped}`
790
801
  if (args.server === true) liveServers.add(tag)
791
802
  if (ports.length > 0) spawnHostBridges(ports, tag)
@@ -796,6 +807,7 @@ export async function wrapSandboxedCommand(
796
807
  srtCommand: inner,
797
808
  baselines,
798
809
  forwardsSignals: grouped.forwards,
810
+ traced: grouped.traced,
799
811
  }
800
812
  }
801
813
 
@@ -823,7 +835,11 @@ export async function wrapSandboxedCommand(
823
835
  * Tools resolve on vx's own PATH; without `setsid` the command keeps the
824
836
  * shared group, as before, and `forwards` says the channel is not there.
825
837
  */
826
- function ownGroupCommand(tag: string, userCommand: string): { command: string; forwards: boolean } {
838
+ function ownGroupCommand(
839
+ tag: string,
840
+ userCommand: string,
841
+ trace?: 'plain' | 'seccomp',
842
+ ): { command: string; forwards: boolean; traced: boolean } {
827
843
  // `sh`, as an unsandboxed task runs (`runner.ts`): the command ran under
828
844
  // bash here, so `[[ … ]]`, brace expansion and `echo 'a\tb'` read one
829
845
  // way sandboxed and another unsandboxed or on a remote executor, where
@@ -832,22 +848,66 @@ function ownGroupCommand(tag: string, userCommand: string): { command: string; f
832
848
  try {
833
849
  sh = executablePath('sh')
834
850
  } catch {
835
- return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false }
851
+ return { command: `: 'vx-${tag}'; ${userCommand}`, forwards: false, traced: false }
836
852
  }
837
- let setsid: string
853
+ const tag0 = `: 'vx-${tag}';`
854
+ let setsid: string | undefined
838
855
  try {
839
- setsid = executablePath('setsid')
856
+ setsid = `${shellQuote(executablePath('setsid'))} `
840
857
  } catch {
841
- return {
842
- command: `: 'vx-${tag}'; exec ${shellQuote(sh)} -c ${shellQuote(userCommand)}`,
843
- forwards: false,
844
- }
858
+ setsid = undefined
845
859
  }
846
- const watch = `{ IFS= read -r s && kill -s "$s" -- "-$$"; } 2>/dev/null <&3 3<&- &`
847
- const run = `exec ${shellQuote(setsid)} ${shellQuote(sh)} -c ${shellQuote(userCommand)} 3<&-`
848
- return { command: `: 'vx-${tag}'; ${watch} ${run}`, forwards: true }
860
+ if (trace === undefined) {
861
+ const run = `exec ${setsid ?? ''}${shellQuote(sh)} -c ${shellQuote(userCommand)}`
862
+ if (setsid === undefined) return { command: `${tag0} ${run}`, forwards: false, traced: false }
863
+ const watch = `{ IFS= read -r s && kill -s "$s" -- "-$$"; } 2>/dev/null <&3 3<&- &`
864
+ return { command: `${tag0} ${watch} ${run} 3<&-`, forwards: true, traced: false }
865
+ }
866
+ // Traced, strace runs INSIDE the sandbox, around the command alone:
867
+ // outside, it followed bwrap building the namespace, a ptrace stop per
868
+ // `openat` of the setup, 17 of a sandboxed `true`'s 45 ms (B-11). It
869
+ // writes to the host's log through descriptor TRACE_FD, which the
870
+ // command's shell closes first, so the task cannot reach the trace.
871
+ // `-DD`: the command keeps the pid it was forked with and strace forks
872
+ // off it into a group of its own, so a `sleep 10 &` the command leaves
873
+ // behind is not a tracee strace waits for: the shell's `wait` ends with
874
+ // the command, the namespace with the shell, and strace goes with it. A tracee stops at each `openat` until strace
875
+ // has written its line, so the kill loses none. `-qq`: nothing of
876
+ // strace's own about the processes it follows.
877
+ // Forked, never `exec`'d: `-DD`'s process waits for ANY child to hear
878
+ // the tracer attached, so one it inherited (the watcher, SRT's network
879
+ // bridges) that exits first sent the command on untraced, and under
880
+ // `--seccomp-bpf` its `execve` failed ENOSYS. A fresh fork has none.
881
+ // An async list starts with SIGINT and SIGQUIT ignored, and a shell
882
+ // cannot trap what it was started ignoring: SRT's bash may put them
883
+ // back before the `exec` (dash may not), so a task's `trap … INT` hears
884
+ // vx's cancellation as it does untraced.
885
+ const tracer = [
886
+ executablePath('strace'),
887
+ '-DD',
888
+ '-f',
889
+ ...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
890
+ '-qq',
891
+ '-e',
892
+ 'trace=openat',
893
+ '-o',
894
+ `/dev/fd/${TRACE_FD}`,
895
+ '--',
896
+ ]
897
+ .map(shellQuote)
898
+ .join(' ')
899
+ const body = shellQuote(`exec ${TRACE_FD}>&-; ${userCommand}`)
900
+ const run = `{ trap - INT QUIT; exec ${setsid ?? ''}${tracer} ${shellQuote(sh)} -c ${body} 3<&-; } & c=$!;`
901
+ if (setsid === undefined) {
902
+ return { command: `${tag0} ${run} wait "$c"`, forwards: false, traced: true }
903
+ }
904
+ const watch = `{ IFS= read -r s && kill -s "$s" -- "-$c"; } 2>/dev/null <&3 3<&- &`
905
+ return { command: `${tag0} ${run} ${watch} wait "$c"`, forwards: true, traced: true }
849
906
  }
850
907
 
908
+ /** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
909
+ const TRACE_FD = 5
910
+
851
911
  /** The ports a list grants, deduped; `true` bridges nothing (the host sees no port on Linux). */
852
912
  export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): number[] {
853
913
  return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
@@ -971,11 +1031,14 @@ const TRACER_RETRY_LINE =
971
1031
  * `initSandbox()` first.
972
1032
  *
973
1033
  * On Linux the task runs under strace, which only REPORTS what the sandbox
974
- * denied, and a traced task's exit is strace's. strace failing on its own
975
- * (`ptrace(PTRACE_LISTEN,…): Input/output error`, after a build that had
976
- * finished) turned green work red on CI five times (STATUS Next 24). Such an
977
- * attempt, its last word strace's own, is run once more: the sandbox kept
978
- * its writes to what it declared, so a second run redoes, not doubles, it.
1034
+ * denied. strace failing on its own (`ptrace(PTRACE_LISTEN,…): Input/output
1035
+ * error`, after a build that had finished) turned green work red on CI five
1036
+ * times (STATUS Next 24): its exit was the task's. Since B-11 it is not —
1037
+ * strace runs detached (`-DD`), and a tracer that dies leaves the command
1038
+ * running untraced — but the trace then stops short, and a denial after it
1039
+ * goes unreported. So an attempt whose stderr carries strace's own word is
1040
+ * run once more, whatever its exit: the sandbox kept its writes to what it
1041
+ * declared, so a second run redoes, not doubles, it.
979
1042
  */
980
1043
  export async function runSandboxed(args: SandboxedRunArgs): Promise<SandboxedRunResult> {
981
1044
  const { tracerFailed, ...first } = await runSandboxedOnce(args)
@@ -1029,7 +1092,7 @@ function collectRecords(
1029
1092
  }
1030
1093
  }
1031
1094
 
1032
- /** strace's own message, as the last line a failed traced task printed. */
1095
+ /** strace's own message, a line of a traced task's stderr. */
1033
1096
  const STRACE_OWN_ERROR = /^strace: /
1034
1097
 
1035
1098
  /**
@@ -1046,17 +1109,12 @@ async function runSandboxedOnce(
1046
1109
  ): Promise<SandboxedRunResult & { tracerFailed: boolean }> {
1047
1110
  const start = Date.now()
1048
1111
  const { SandboxManager } = await loadSrt()
1049
- const { wrapped, tag, srtCommand, baselines, forwardsSignals } = await wrapSandboxedCommand(args)
1050
- const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
1051
-
1052
1112
  // Linux: SRT's store sees only writes (below), so read denials need
1053
- // the spawn wrapped with strace and the trace parsed for denied syscalls. The trace is per-task (unique
1054
- // log path keyed by the command tag) so parallel tasks don't share
1055
- // a stream. Skipped when strace isn't on PATH — bwrap still enforces
1056
- // structurally; we just lose the structured violation list.
1057
- const useStrace = await wantsStraceDetection()
1058
- const straceLog = useStrace ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
1059
- if (straceLog) unlinkOnExit(straceLog)
1113
+ // the command traced and the trace parsed for denied syscalls. The trace
1114
+ // is per-task (a log keyed by the command tag) so parallel tasks don't
1115
+ // share a stream. Skipped when strace isn't on PATH — bwrap still
1116
+ // enforces structurally; we just lose the structured violation list.
1117
+ //
1060
1118
  // We trace only `openat` — it's the actual file-read attempt, the
1061
1119
  // signal the user cares about. `statx` / `newfstatat` / `access`
1062
1120
  // are mostly shell PATH-walking and stat probes that aren't
@@ -1073,56 +1131,37 @@ async function runSandboxedOnce(
1073
1131
  // it taxed every other sandboxed task the same way. With the flag the
1074
1132
  // kernel filter stops only on `openat`. strace ≥ 5.3 (2019); an older
1075
1133
  // one gets the slow form rather than no detection.
1134
+ const useStrace = await wantsStraceDetection()
1135
+ const { wrapped, tag, srtCommand, baselines, forwardsSignals, traced } =
1136
+ await wrapSandboxedCommand({ ...args, ...(useStrace ? { trace: useStrace } : {}) })
1137
+ const takeRecords = collectRecords(SandboxManager.getSandboxViolationStore(), srtCommand)
1138
+ const straceLog = traced ? path.join(os.tmpdir(), `vx-strace-${tag}.log`) : undefined
1139
+ if (straceLog) unlinkOnExit(straceLog)
1076
1140
  let proc: ReturnType<typeof Bun.spawn>
1141
+ let traceFd: number | undefined
1077
1142
  try {
1078
- // Both tools resolved on vx's own PATH (util/which.ts): strace would
1079
- // otherwise walk the task's PATH for `sh`, where a project's
1080
- // node_modules/.bin comes first.
1143
+ // Resolved on vx's own PATH (util/which.ts), not the task's, where a
1144
+ // project's node_modules/.bin comes first.
1081
1145
  const sh = executablePath('sh')
1082
- const spawnArgv = straceLog
1083
- ? [
1084
- executablePath('strace'),
1085
- '-f',
1086
- ...(useStrace === 'seccomp' ? ['--seccomp-bpf'] : []),
1087
- '-e',
1088
- 'trace=openat',
1089
- '-o',
1090
- straceLog,
1091
- '--',
1092
- sh,
1093
- '-c',
1094
- wrapped,
1095
- ]
1096
- : [sh, '-c', wrapped]
1097
- // With the guard, a shell lists the group first and `exec`s the
1098
- // spawn: strace itself would hold the guard's pipe for its lifetime.
1099
- const guardAt = forwardsSignals ? 4 : 3
1146
+ if (straceLog) traceFd = openSync(straceLog, 'w')
1147
+ // Descriptors: 3 the signal channel the in-sandbox watcher reads, 4 the
1148
+ // guard's pipe (kill-tree.ts), TRACE_FD the trace log; a gap is 'ignore'.
1100
1149
  proc = spawnGuarded((guard) =>
1101
- Bun.spawn(
1102
- guard === undefined
1103
- ? spawnArgv
1104
- : [
1105
- sh,
1106
- '-c',
1107
- guardLine(guardAt) +
1108
- (straceLog ? `exec ${spawnArgv.map(shellQuote).join(' ')}` : wrapped),
1109
- ],
1110
- {
1111
- argv0: straceLog && guard === undefined ? 'strace' : 'sh',
1112
- cwd: args.cwd,
1113
- env: args.env as Record<string, string>,
1114
- // fd 3 is the signal channel the in-sandbox watcher reads.
1115
- stdio: [
1116
- 'ignore',
1117
- 'pipe',
1118
- 'pipe',
1119
- ...(forwardsSignals ? ['pipe' as const] : []),
1120
- ...(guard === undefined ? [] : [guard]),
1121
- ],
1122
- // As the unsandboxed spawn: its own process group (kill-tree.ts).
1123
- detached: true,
1124
- },
1125
- ),
1150
+ Bun.spawn([sh, '-c', (guard === undefined ? '' : guardLine(4)) + wrapped], {
1151
+ argv0: 'sh',
1152
+ cwd: args.cwd,
1153
+ env: args.env as Record<string, string>,
1154
+ stdio: [
1155
+ 'ignore',
1156
+ 'pipe',
1157
+ 'pipe',
1158
+ forwardsSignals ? 'pipe' : 'ignore',
1159
+ guard ?? 'ignore',
1160
+ ...(traceFd === undefined ? [] : [traceFd]),
1161
+ ],
1162
+ // As the unsandboxed spawn: its own process group (kill-tree.ts).
1163
+ detached: true,
1164
+ }),
1126
1165
  )
1127
1166
  if (forwardsSignals) signalThrough(proc, proc.stdio[3] as number)
1128
1167
  } catch (err) {
@@ -1138,20 +1177,27 @@ async function runSandboxedOnce(
1138
1177
  violations: [],
1139
1178
  tracerFailed: false,
1140
1179
  }
1180
+ } finally {
1181
+ // The child holds its own copy; ours would keep nothing but a descriptor.
1182
+ if (traceFd !== undefined) closeSync(traceFd)
1141
1183
  }
1142
1184
 
1143
1185
  args.liveChildren?.add(proc)
1144
1186
  const timeout = armTimeout(proc, args.timeoutMs)
1145
1187
  const ac = new AbortController()
1146
- let stderrTail = ''
1188
+ // The unfinished last line of stderr, and whether a line was strace's.
1189
+ let partial = ''
1190
+ let straceSpoke = false
1147
1191
  const streams = Promise.all([
1148
1192
  streamToString(proc.stdout, args.onStdout, ac.signal, args.capture?.stdout ?? true),
1149
1193
  streamToString(
1150
1194
  proc.stderr,
1151
1195
  (chunk) => {
1152
- // Kept whatever the capture setting: the last line says whether
1153
- // strace, not the task, ended the attempt.
1154
- stderrTail = (stderrTail + chunk).slice(-1024)
1196
+ // Read whatever the capture setting: a line of strace's own says
1197
+ // the trace stopped short.
1198
+ const lines = (partial + chunk).split('\n')
1199
+ partial = (lines.pop() ?? '').slice(0, 64)
1200
+ if (lines.some((l) => STRACE_OWN_ERROR.test(l))) straceSpoke = true
1155
1201
  args.onStderr?.(chunk)
1156
1202
  },
1157
1203
  ac.signal,
@@ -1289,7 +1335,6 @@ async function runSandboxedOnce(
1289
1335
  // ignore; bwrap mount-point cleanup is best-effort
1290
1336
  }
1291
1337
 
1292
- const lastLine = stderrTail.trimEnd().split('\n').pop() ?? ''
1293
1338
  return {
1294
1339
  exitCode,
1295
1340
  durationMs: Date.now() - start,
@@ -1309,9 +1354,8 @@ async function runSandboxedOnce(
1309
1354
  ...(process.platform === 'linux' ? {} : resourceUsageToCpuRss(proc.resourceUsage())),
1310
1355
  tracerFailed:
1311
1356
  straceLog !== undefined &&
1312
- exitCode !== 0 &&
1313
1357
  !timeout.timedOut() &&
1314
- STRACE_OWN_ERROR.test(lastLine),
1358
+ (straceSpoke || STRACE_OWN_ERROR.test(partial)),
1315
1359
  }
1316
1360
  }
1317
1361
 
@@ -22,6 +22,7 @@ import {
22
22
  releaseBridges,
23
23
  wrapSandboxedCommand,
24
24
  signalExitCode,
25
+ isLocalExecutor,
25
26
  type CaptureConfig,
26
27
  type ExecuteRequest,
27
28
  assertExecuteResult,
@@ -32,7 +33,9 @@ import {
32
33
  PersistentReadyError,
33
34
  } from '../exec/index.js'
34
35
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
35
- import { relPosix, span } from '../util/index.js'
36
+ import { killGraceMs, relPosix, span } from '../util/index.js'
37
+ import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
38
+ import { executorLabel } from './plugin-host.js'
36
39
  import {
37
40
  mayWriteFingerprint,
38
41
  type Placeholder,
@@ -493,6 +496,49 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
493
496
  }
494
497
  return stop.signal
495
498
  }
499
+ // An executor that ignores `signal` held its task, and so the run, past
500
+ // the stop and past `exec.timeout` (H-14). Once the signal aborts it has
501
+ // the kill grace the local executor gives a process group; then core
502
+ // settles the attempt without it and says so. Its work is abandoned: core
503
+ // cannot reach what it started.
504
+ function boundAfterAbort(
505
+ running: Promise<unknown>,
506
+ signal: AbortSignal | undefined,
507
+ ): Promise<unknown> {
508
+ if (signal === undefined || isLocalExecutor(args.executor)) return running
509
+ const started = Date.now()
510
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
511
+ let onAbort: (() => void) | undefined
512
+ const abandoned = new Promise<ExecuteResult>((resolve) => {
513
+ onAbort = (): void => {
514
+ const graceMs = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS)
515
+ graceTimer = setTimeout(() => {
516
+ const why = timeoutFired ? `timeout (${effectiveTimeout}ms)` : 'stop'
517
+ log.taskStderr(
518
+ node,
519
+ `vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
520
+ )
521
+ const reason: unknown = signal.reason
522
+ resolve({
523
+ exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
524
+ durationMs: Date.now() - started,
525
+ stdout: '',
526
+ stderr: '',
527
+ violations: [],
528
+ ...(timeoutFired ? { timedOut: true } : {}),
529
+ })
530
+ }, graceMs)
531
+ }
532
+ if (signal.aborted) onAbort()
533
+ else signal.addEventListener('abort', onAbort, { once: true })
534
+ })
535
+ // The abandoned call may still settle, or reject, later: nobody awaits it.
536
+ running.catch(() => {})
537
+ return Promise.race([running, abandoned]).finally(() => {
538
+ clearTimeout(graceTimer)
539
+ if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)
540
+ })
541
+ }
496
542
 
497
543
  // When the task started, as a ns offset from run start — captured for
498
544
  // EVERY outcome (hits included) so the run-detail timeline reflects when
@@ -722,8 +768,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
722
768
  // no other trace. Rethrown unchanged — the scheduler still classifies it,
723
769
  // and still prints it plainly for a UserError.
724
770
  const endExec = span('miss: execute')
725
- let res = await args.executor
726
- .execute(req)
771
+ let res = await boundAfterAbort(args.executor.execute(req), req.signal)
727
772
  .then((r: unknown) => {
728
773
  assertExecuteResult(args.executor.name, node.id, r)
729
774
  return r
@@ -199,7 +199,6 @@ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: stri
199
199
  if (prefixes.length === 0) return out
200
200
  const reaches = (dir: string): boolean =>
201
201
  dir === '' ||
202
- dir === '.' ||
203
202
  prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
204
203
  const dependants = new Map<string, string[]>()
205
204
  for (const node of nodes.values()) {
@@ -241,58 +241,24 @@ export function getInvocation(db: Database, runId: string): InvocationDetail | n
241
241
 
242
242
  export interface ListInvocationsArgs {
243
243
  limit?: number
244
- branch?: string
245
- ci?: boolean
246
- tagKey?: string
247
- tagValue?: string
248
244
  }
249
245
 
250
246
  /**
251
- * List `vx run` invocations newest-first from the `invocations` header table
252
- * with optional branch / ci / tag filters. Reading the dedicated header table
253
- * (vs the old `GROUP BY run_id` over `runs`) is lossless — git/ci/tag context
254
- * survives. Accepts a bare `number` for the limit (back-compat with the old
255
- * `listInvocations(db, 50)` signature).
247
+ * List `vx run` invocations newest-first from the `invocations` header table.
248
+ * Reading the dedicated header table (vs the old `GROUP BY run_id` over
249
+ * `runs`) is lossless — git/ci/tag context survives.
256
250
  */
257
- export function listInvocations(
258
- db: Database,
259
- args: ListInvocationsArgs | number = {},
260
- ): InvocationDetail[] {
261
- const opts: ListInvocationsArgs = typeof args === 'number' ? { limit: args } : args
262
- const limit = clampInt(opts.limit ?? 50, 1, 500)
263
- const where: string[] = []
264
- const params: (string | number)[] = []
265
- if (opts.branch !== undefined) {
266
- where.push('branch = ?')
267
- params.push(opts.branch)
268
- }
269
- if (opts.ci !== undefined) {
270
- where.push('ci = ?')
271
- params.push(opts.ci ? 1 : 0)
272
- }
273
- if (opts.tagKey !== undefined && opts.tagValue !== undefined) {
274
- // The tags column is a JSON object {"k":"v"}; a LIKE over the serialized
275
- // pair is adequate at this table's scale (see the design doc).
276
- where.push('tags LIKE ?')
277
- params.push(`%${jsonPairFragment(opts.tagKey, opts.tagValue)}%`)
278
- }
279
- const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
251
+ export function listInvocations(db: Database, args: ListInvocationsArgs = {}): InvocationDetail[] {
252
+ const limit = clampInt(args.limit ?? 50, 1, 500)
280
253
  const rows = db
281
254
  .query(
282
- `SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
255
+ `SELECT ${INVOCATION_COLUMNS} FROM invocations
283
256
  ORDER BY rowid DESC LIMIT ?`,
284
257
  )
285
- .all(...params, limit) as InvocationRawRow[]
258
+ .all(limit) as InvocationRawRow[]
286
259
  return rows.map(mapInvocation)
287
260
  }
288
261
 
289
- /** The `"key":"value"` fragment as it appears in `JSON.stringify({k:v})`. */
290
- function jsonPairFragment(key: string, value: string): string {
291
- const k = JSON.stringify(key).slice(1, -1)
292
- const v = JSON.stringify(value).slice(1, -1)
293
- return `"${k}":"${v}"`
294
- }
295
-
296
262
  export interface RunDetail {
297
263
  runId: string
298
264
  startedAt: number
@@ -77,6 +77,8 @@ export interface RunPlan {
77
77
  * equivalent `vx run` would refuse to start.
78
78
  */
79
79
  unresolvedTasks?: readonly string[]
80
+ /** What `vx run` appends to that refusal: the nearest declared names, or how to declare one. */
81
+ unresolvedHint?: string
80
82
  /**
81
83
  * Producers `--download` WANTED to defer but could not, mapped to why.
82
84
  * Without this the gate is invisible: a user asks for `--download=none`,
@@ -108,10 +108,13 @@ export async function applyConfigHooks(
108
108
  plugins: readonly VxPlugin[],
109
109
  workspace: WorkspaceConfig,
110
110
  ctx: WorkspaceHookContext,
111
+ /** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
112
+ afterEach?: (plugin: VxPlugin) => void,
111
113
  ): Promise<void> {
112
114
  for (const plugin of plugins) {
113
115
  if (plugin.config === undefined) continue
114
116
  await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
117
+ afterEach?.(plugin)
115
118
  }
116
119
  }
117
120
 
@@ -56,6 +56,7 @@ export interface VxPlugin {
56
56
  /**
57
57
  * The workspace config, before anything is derived from it (concurrency,
58
58
  * cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
59
+ * Core re-validates after each plugin, as it does a user's file.
59
60
  */
60
61
  config?(workspace: WorkspaceConfig, ctx: WorkspaceHookContext): void | Promise<void>
61
62
 
@@ -26,6 +26,7 @@ import {
26
26
  loadWorkspace,
27
27
  loadWorkspaceConfig,
28
28
  resolveCacheDir,
29
+ validateWorkspace,
29
30
  } from '../workspace/index.js'
30
31
  import { parseDependencySpec } from '../graph/index.js'
31
32
  import { applyConfigHooks, applyProjectHooks, hasHook } from './plugin-host.js'
@@ -43,7 +44,12 @@ export async function loadWorkspacePlugins(
43
44
  const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
44
45
  const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
45
46
  if (workspaceConfig !== null && hasHook(plugins, 'config')) {
46
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn })
47
+ // Re-validated after each plugin, as the `project` stage is: an edit
48
+ // the loader would refuse from a user (`concurrency: -3`) hung the run,
49
+ // and `cacheDir: 42` reached `path.resolve` as a TypeError.
50
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
51
+ validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
52
+ )
47
53
  }
48
54
  return { workspaceConfig, plugins }
49
55
  }
@@ -1206,7 +1206,11 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1206
1206
  const prepared = await prepareRun(options, log)
1207
1207
  try {
1208
1208
  if (prepared.unresolvedTasks.length > 0) {
1209
- return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
1209
+ return {
1210
+ tasks: [],
1211
+ unresolvedTasks: prepared.unresolvedTasks,
1212
+ unresolvedHint: `${didYouMean(prepared.unresolvedTasks, prepared.projects)}${await initHint(prepared)}`,
1213
+ }
1210
1214
  }
1211
1215
  if (prepared.empty !== null) return { tasks: [] }
1212
1216
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
@@ -34,7 +34,7 @@ export {
34
34
  loadWorkspaceConfig,
35
35
  WORKSPACE_CONFIG_FILENAMES,
36
36
  } from './project-loader.js'
37
- export { validateProjectConfig } from './config-schema.js'
37
+ export { validateProjectConfig, validateWorkspace } from './config-schema.js'
38
38
  export type { LoadProjectConfigOptions } from './project-loader.js'
39
39
  export {
40
40
  blobOidOf,