@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 +2 -1
- package/package.json +5 -5
- package/src/cache/cache.ts +2 -206
- package/src/cache/schema.ts +215 -0
- package/src/cli/run.ts +1 -1
- package/src/exec/executor.ts +5 -3
- package/src/exec/index.ts +1 -1
- package/src/exec/local-executor.ts +14 -1
- package/src/exec/sandbox-runtime.ts +130 -86
- package/src/orchestrator/execute-task.ts +48 -3
- package/src/orchestrator/local-shortcircuit.ts +0 -1
- package/src/orchestrator/metrics.ts +7 -41
- package/src/orchestrator/plan.ts +2 -0
- package/src/orchestrator/plugin-host.ts +3 -0
- package/src/orchestrator/plugin.ts +1 -0
- package/src/orchestrator/projects.ts +7 -1
- package/src/orchestrator/run.ts +5 -1
- package/src/workspace/index.ts +1 -1
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
|
-
|
|
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.
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
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"
|
package/src/cache/cache.ts
CHANGED
|
@@ -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
|
-
|
|
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(', ')}
|
|
588
|
+
`vx run: no projects declare task(s): ${plan.unresolvedTasks.join(', ')}.${plan.unresolvedHint ?? ''}\n`,
|
|
589
589
|
)
|
|
590
590
|
return 1
|
|
591
591
|
}
|
package/src/exec/executor.ts
CHANGED
|
@@ -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
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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
|
-
|
|
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 {
|
|
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
|
|
787
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
842
|
-
command: `: 'vx-${tag}'; exec ${shellQuote(sh)} -c ${shellQuote(userCommand)}`,
|
|
843
|
-
forwards: false,
|
|
844
|
-
}
|
|
858
|
+
setsid = undefined
|
|
845
859
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
*
|
|
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,
|
|
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
|
|
1054
|
-
// log
|
|
1055
|
-
// a stream. Skipped when strace isn't on PATH — bwrap still
|
|
1056
|
-
// structurally; we just lose the structured violation list.
|
|
1057
|
-
|
|
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
|
-
//
|
|
1079
|
-
//
|
|
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
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
|
|
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(
|
|
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
|
-
*
|
|
253
|
-
*
|
|
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
|
-
|
|
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
|
|
255
|
+
`SELECT ${INVOCATION_COLUMNS} FROM invocations
|
|
283
256
|
ORDER BY rowid DESC LIMIT ?`,
|
|
284
257
|
)
|
|
285
|
-
.all(
|
|
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
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/workspace/index.ts
CHANGED
|
@@ -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,
|