@vzn/vx 0.0.595 → 0.0.597
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 +1 -1
- package/launcher.cjs +7 -3
- package/package.json +5 -5
- package/src/cache/archive.ts +35 -6
- package/src/cache/cache.ts +170 -99
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/git-inputs.ts +23 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +99 -44
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +9 -6
- package/src/cache/policy.ts +5 -0
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +12 -6
- package/src/cli/foreign-flags.ts +57 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/init.ts +2 -2
- package/src/cli/last.ts +10 -4
- package/src/cli/lock.ts +5 -3
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/run.ts +73 -30
- package/src/cli/select.ts +6 -3
- package/src/cli/show.ts +8 -2
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch-filter.ts +63 -12
- package/src/cli/watch-fs.ts +11 -3
- package/src/cli/watch-judge.ts +15 -8
- package/src/cli/watch.ts +18 -11
- package/src/cli/workspace-config.ts +3 -1
- package/src/config.ts +42 -21
- package/src/exec/executor.ts +1 -1
- package/src/exec/kill-tree.ts +89 -18
- package/src/exec/proc-sample.ts +1 -1
- package/src/exec/runner.ts +74 -36
- package/src/exec/sandbox-paths.ts +12 -1
- package/src/exec/sandbox-runtime.ts +283 -88
- package/src/exec/sandbox-violations.ts +115 -15
- package/src/graph/scheduler.ts +30 -14
- package/src/graph/task-graph.ts +265 -44
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/affected-tasks.ts +6 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/excluded-keys.ts +2 -13
- package/src/orchestrator/execute-task.ts +85 -39
- package/src/orchestrator/failure-mode.ts +43 -28
- package/src/orchestrator/framed-output.ts +8 -8
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/keyed-projects.ts +6 -3
- package/src/orchestrator/logger.ts +21 -9
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +9 -13
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +7 -23
- package/src/orchestrator/plugin.ts +37 -16
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +4 -3
- package/src/orchestrator/run-lock.ts +25 -24
- package/src/orchestrator/run.ts +22 -5
- package/src/orchestrator/sandbox-request.ts +96 -18
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +22 -4
- package/src/orchestrator/stable-keys.ts +3 -11
- package/src/orchestrator/summary.ts +7 -13
- package/src/orchestrator/task-hash.ts +13 -3
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/orchestrator/upstream.ts +11 -0
- package/src/util/hangup.ts +42 -0
- package/src/util/index.ts +2 -0
- package/src/util/paths.ts +24 -4
- package/src/workspace/affected.ts +79 -42
- package/src/workspace/config-eval.ts +27 -6
- package/src/workspace/config-imports.ts +8 -53
- package/src/workspace/config-schema.ts +56 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/lockfile.ts +2 -1
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +19 -4
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +53 -24
package/src/cli/last.ts
CHANGED
|
@@ -60,7 +60,10 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
60
60
|
if (spaced) i++
|
|
61
61
|
const n = Number(lv)
|
|
62
62
|
if (!Number.isInteger(n) || n < 1 || n > 500) {
|
|
63
|
-
return {
|
|
63
|
+
return {
|
|
64
|
+
...out,
|
|
65
|
+
error: `--list must be a whole number from 1 to 500 (got ${lv})${seeHelp('last')}`,
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
out.list = n
|
|
66
69
|
continue
|
|
@@ -77,7 +80,7 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
77
80
|
}
|
|
78
81
|
const cd = parseCacheDirFlag(args, i)
|
|
79
82
|
if (cd !== null) {
|
|
80
|
-
if ('error' in cd) return { ...out, error: cd.error }
|
|
83
|
+
if ('error' in cd) return { ...out, error: `${cd.error}${seeHelp('last')}` }
|
|
81
84
|
out.cacheDir = cd.cacheDir
|
|
82
85
|
i = cd.next
|
|
83
86
|
continue
|
|
@@ -96,11 +99,14 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
|
|
|
96
99
|
if (out.runId !== undefined && out.list !== undefined) {
|
|
97
100
|
return {
|
|
98
101
|
...out,
|
|
99
|
-
error: `a run id and --list do not combine: replay ${out.runId}, or list runs`,
|
|
102
|
+
error: `a run id and --list do not combine: replay ${out.runId}, or list runs${seeHelp('last')}`,
|
|
100
103
|
}
|
|
101
104
|
}
|
|
102
105
|
if (out.runId !== undefined && out.failed === true) {
|
|
103
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
...out,
|
|
108
|
+
error: `a run id and --failed do not combine: replay ${out.runId}${seeHelp('last')}`,
|
|
109
|
+
}
|
|
104
110
|
}
|
|
105
111
|
return out
|
|
106
112
|
}
|
package/src/cli/lock.ts
CHANGED
|
@@ -83,9 +83,11 @@ function bareNote(bare: number): string {
|
|
|
83
83
|
|
|
84
84
|
async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): Promise<number> {
|
|
85
85
|
const entries = await Promise.all(metas.map((m) => evaluateEntry(root, m)))
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
86
|
+
// `listProjects` sorts by name — stable lockfile diffs for free. Defined,
|
|
87
|
+
// not assigned: a project named `__proto__` set the map's prototype (D-152).
|
|
88
|
+
const projects: Record<string, LockfileEntry> = Object.fromEntries(
|
|
89
|
+
metas.map((m, i) => [m.name, entries[i]!]),
|
|
90
|
+
)
|
|
89
91
|
const leaks = secretsIn(projects)
|
|
90
92
|
if (leaks.length > 0) {
|
|
91
93
|
process.stderr.write(
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Formatters for `--dry-run` (human / JSON) and `--graph` (DOT).
|
|
2
2
|
|
|
3
3
|
import type { CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
|
-
import {
|
|
4
|
+
import { formatElapsed } from '../util/index.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Human-readable preview. One line per real task (groups hidden, same
|
|
@@ -37,7 +37,7 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
37
37
|
const desc = skipped ? 'noop — would not run' : describe(t.cacheStatus)
|
|
38
38
|
const shortHash = t.hash ? t.hash.slice(0, 8) : ''
|
|
39
39
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
40
|
-
const eta = executes && t.p50Ms !== undefined ? ` ~${
|
|
40
|
+
const eta = executes && t.p50Ms !== undefined ? ` ~${formatElapsed(t.p50Ms)}` : ''
|
|
41
41
|
// Placement, by EXECUTOR NAME rather than a local/remote word: the
|
|
42
42
|
// summary line below already spends "local" and "remote" on the cache
|
|
43
43
|
// tier, and a task placed on a named executor is what the reader can
|
|
@@ -94,8 +94,8 @@ export function formatPlanText(plan: RunPlan): string {
|
|
|
94
94
|
const p = plan.predicted
|
|
95
95
|
const executes = miss + nocache
|
|
96
96
|
if (p !== undefined && executes > 0 && executes > p.unknownCount) {
|
|
97
|
-
const parts = [`predicted: ~${
|
|
98
|
-
parts.push(`~${
|
|
97
|
+
const parts = [`predicted: ~${formatElapsed(p.wallMs)} wall`]
|
|
98
|
+
parts.push(`~${formatElapsed(p.workMs)} total execution`)
|
|
99
99
|
if (p.unknownCount > 0) {
|
|
100
100
|
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
|
|
101
101
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { isatty } from 'node:tty'
|
|
4
|
+
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
4
5
|
import { translateForeign } from './foreign-flags.js'
|
|
5
6
|
import { flagHint, seeHelp } from './help.js'
|
|
6
7
|
import {
|
|
@@ -12,8 +13,13 @@ import {
|
|
|
12
13
|
type RunOptions,
|
|
13
14
|
type RunResult,
|
|
14
15
|
} from '../orchestrator/index.js'
|
|
15
|
-
import type { ContinueMode } from '../graph/index.js'
|
|
16
|
-
import {
|
|
16
|
+
import type { ContinueMode, TaskOutcome } from '../graph/index.js'
|
|
17
|
+
import {
|
|
18
|
+
type CachePolicy,
|
|
19
|
+
FULL_CACHE_POLICY,
|
|
20
|
+
gitRefusal,
|
|
21
|
+
parseCachePolicy,
|
|
22
|
+
} from '../cache/index.js'
|
|
17
23
|
import { affectedFilterFor, findCwdSelection, pickTask, resolveFilters } from './select.js'
|
|
18
24
|
import { nxTargetHint, taskNamesHere } from './task-verb.js'
|
|
19
25
|
import { MAX_TIMEOUT_MS, parseDecimalInt, machineParallelism } from '../util/index.js'
|
|
@@ -175,7 +181,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
175
181
|
if (n === null)
|
|
176
182
|
return {
|
|
177
183
|
...out,
|
|
178
|
-
error:
|
|
184
|
+
error: `--concurrency must be a positive integer, or a share of the cores such as 50% (got ${v})`,
|
|
179
185
|
}
|
|
180
186
|
out.concurrency = n
|
|
181
187
|
} else if (a === '--all') {
|
|
@@ -261,14 +267,17 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
261
267
|
out.download = v
|
|
262
268
|
} else if (a === '--cache-dir' || a?.startsWith('--cache-dir=')) {
|
|
263
269
|
const v = a === '--cache-dir' ? before[++i] : a.slice('--cache-dir='.length)
|
|
264
|
-
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a
|
|
270
|
+
if (v === undefined || v === '') return { ...out, error: `--cache-dir requires a path` }
|
|
265
271
|
// Unlike every other value flag, a cache dir is an arbitrary
|
|
266
272
|
// string — nothing about its shape rejects a swallowed flag. An
|
|
267
273
|
// unquoted empty shell var (`--cache-dir $EMPTY --force`) would
|
|
268
274
|
// otherwise create a directory literally named `--force` and drop
|
|
269
275
|
// the flag. A path starting with `-` needs the `=` form.
|
|
270
276
|
if (a === '--cache-dir' && v.startsWith('-')) {
|
|
271
|
-
return {
|
|
277
|
+
return {
|
|
278
|
+
...out,
|
|
279
|
+
error: `--cache-dir requires a path (got flag ${v}; a path that starts with - needs --cache-dir=${v})`,
|
|
280
|
+
}
|
|
272
281
|
}
|
|
273
282
|
out.cacheDir = v
|
|
274
283
|
} else if (a === '--cache' || a?.startsWith('--cache=')) {
|
|
@@ -323,14 +332,15 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
323
332
|
if (v === undefined)
|
|
324
333
|
return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
|
|
325
334
|
const n = parseDecimalInt(v)
|
|
326
|
-
if (n === null)
|
|
335
|
+
if (n === null)
|
|
336
|
+
return { ...out, error: `--verbosity must be a non-negative integer (got ${v})` }
|
|
327
337
|
out.verbosity = n
|
|
328
338
|
} else if (a === '--dry') {
|
|
329
339
|
out.dry = 'text'
|
|
330
340
|
} else if (a?.startsWith('--dry=')) {
|
|
331
341
|
const fmt = a.slice('--dry='.length)
|
|
332
342
|
if (fmt !== 'text' && fmt !== 'json') {
|
|
333
|
-
return { ...out, error:
|
|
343
|
+
return { ...out, error: `--dry must be text or json (got ${fmt})` }
|
|
334
344
|
}
|
|
335
345
|
out.dry = fmt
|
|
336
346
|
} else if (a === '--graph') {
|
|
@@ -358,7 +368,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
358
368
|
if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
|
|
359
369
|
// Split on the FIRST `=` so values may contain `=` (e.g. a URL).
|
|
360
370
|
const eq = raw.indexOf('=')
|
|
361
|
-
if (eq <= 0) return { ...out, error:
|
|
371
|
+
if (eq <= 0) return { ...out, error: `--tag must be k=v (got ${raw})` }
|
|
362
372
|
out.tags[raw.slice(0, eq)] = raw.slice(eq + 1)
|
|
363
373
|
} else if (a === '--report-file' || a?.startsWith('--report-file=')) {
|
|
364
374
|
const v = a === '--report-file' ? before[++i] : a.slice('--report-file='.length)
|
|
@@ -367,7 +377,10 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
367
377
|
// nothing about its shape rejects a swallowed flag. A path starting
|
|
368
378
|
// with `-` needs the `=` form.
|
|
369
379
|
if (a === '--report-file' && v.startsWith('-')) {
|
|
370
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
...out,
|
|
382
|
+
error: `--report-file requires a path (got flag ${v}; a path that starts with - needs --report-file=${v})`,
|
|
383
|
+
}
|
|
371
384
|
}
|
|
372
385
|
out.reportFile = v
|
|
373
386
|
} else if (a === '--report') {
|
|
@@ -375,7 +388,7 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
375
388
|
} else if (a?.startsWith('--report=')) {
|
|
376
389
|
const fmt = a.slice('--report='.length)
|
|
377
390
|
if (fmt !== 'markdown') {
|
|
378
|
-
return { ...out, error:
|
|
391
|
+
return { ...out, error: `--report must be markdown (got ${fmt})` }
|
|
379
392
|
}
|
|
380
393
|
out.report = fmt
|
|
381
394
|
} else if (a !== undefined && a.startsWith('-')) {
|
|
@@ -486,6 +499,7 @@ export async function resolveRunOptions(
|
|
|
486
499
|
parsed: RunArgs,
|
|
487
500
|
cwd: string,
|
|
488
501
|
tasks: readonly string[],
|
|
502
|
+
verb: 'run' | 'watch' = 'run',
|
|
489
503
|
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
490
504
|
for (const t of tasks) {
|
|
491
505
|
const idx = t.indexOf('#')
|
|
@@ -557,8 +571,7 @@ export async function resolveRunOptions(
|
|
|
557
571
|
const nx = await nxTargetHint(tasks, cwd)
|
|
558
572
|
if (nx !== null) return { error: nx }
|
|
559
573
|
return {
|
|
560
|
-
error:
|
|
561
|
-
'not inside a project. Pass --all for every project, --filter <pattern> to filter, or run from within a project directory.',
|
|
574
|
+
error: `not inside a project: run from a project directory, or pass --all or --filter <pattern>${seeHelp(verb)}`,
|
|
562
575
|
}
|
|
563
576
|
}
|
|
564
577
|
projects = [cwdProject.name]
|
|
@@ -620,6 +633,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
620
633
|
|
|
621
634
|
const cwd = process.cwd()
|
|
622
635
|
let tasks = [...parsed.tasks]
|
|
636
|
+
let picked: string | undefined
|
|
623
637
|
|
|
624
638
|
// No positionals → interactive picker (TTY only). Yields a single
|
|
625
639
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
@@ -638,6 +652,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
638
652
|
)
|
|
639
653
|
return 1
|
|
640
654
|
}
|
|
655
|
+
// Every run needs git: refused here, not after the user has chosen.
|
|
656
|
+
const refusal = gitRefusal(await findWorkspaceRoot(cwd))
|
|
657
|
+
if (refusal !== undefined) {
|
|
658
|
+
process.stderr.write(`${refusal.message}\n`)
|
|
659
|
+
return 1
|
|
660
|
+
}
|
|
641
661
|
const load = {
|
|
642
662
|
...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
|
|
643
663
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
@@ -662,10 +682,11 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
662
682
|
}
|
|
663
683
|
only = new Set(selected.names)
|
|
664
684
|
}
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
667
|
-
if (!
|
|
668
|
-
|
|
685
|
+
const chosen = await pickTask(cwd, {}, load, only)
|
|
686
|
+
if (chosen === 'interrupted') return 130
|
|
687
|
+
if (!chosen) return 1
|
|
688
|
+
picked = `${chosen.project}#${chosen.task}`
|
|
689
|
+
tasks = [picked]
|
|
669
690
|
}
|
|
670
691
|
|
|
671
692
|
const resolved = await resolveRunOptions(parsed, cwd, tasks)
|
|
@@ -680,8 +701,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
680
701
|
const opts = resolved
|
|
681
702
|
// The raw invocation, recorded on the `invocations` row so dashboards
|
|
682
703
|
// show what was actually run. `args` is everything after `run`, quoted
|
|
683
|
-
// so `vx last` replays one paste-able line
|
|
684
|
-
|
|
704
|
+
// so `vx last` replays one paste-able line; a picked task is named in
|
|
705
|
+
// it, or the line would open the picker again.
|
|
706
|
+
opts.command = [
|
|
707
|
+
'vx',
|
|
708
|
+
'run',
|
|
709
|
+
...(picked !== undefined ? [shellQuote(picked)] : []),
|
|
710
|
+
...args.map(shellQuote),
|
|
711
|
+
].join(' ')
|
|
685
712
|
|
|
686
713
|
// Planning paths short-circuit execution. Both build the full task
|
|
687
714
|
// graph + probe the cache; the difference is just the formatter.
|
|
@@ -726,6 +753,25 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
726
753
|
return 0
|
|
727
754
|
}
|
|
728
755
|
|
|
756
|
+
// The report prints above the footer: nothing prints below it (owner).
|
|
757
|
+
// Rendered once, however many sinks asked for it; a kept server's crash
|
|
758
|
+
// after the footer re-renders the file's copy (C-53).
|
|
759
|
+
const wantsReport = parsed.report === 'markdown' || parsed.reportFile !== undefined
|
|
760
|
+
let reported: { outcomes: readonly TaskOutcome[]; md: string } | undefined
|
|
761
|
+
if (wantsReport) {
|
|
762
|
+
opts.beforeFooter = (outcomes, ok) => {
|
|
763
|
+
reported = {
|
|
764
|
+
outcomes,
|
|
765
|
+
md: formatRunReportMarkdown({ ok, outcomes: outcomes.map(projectOutcome) }),
|
|
766
|
+
}
|
|
767
|
+
// `--report` writes stdout. The report ITSELF is machine-clean, but
|
|
768
|
+
// stdout is not vx's alone — the status logger writes there too, so
|
|
769
|
+
// `--report=markdown >> "$GITHUB_STEP_SUMMARY"` captures every frame,
|
|
770
|
+
// meter bar and `::group::` command above the table. `--report-file`
|
|
771
|
+
// is the redirect-free form.
|
|
772
|
+
return parsed.report === 'markdown' ? reported.md : ''
|
|
773
|
+
}
|
|
774
|
+
}
|
|
729
775
|
// A run executes in THIS process, always. Where an individual task's
|
|
730
776
|
// command runs is the `executor` capability's business (per task, with
|
|
731
777
|
// the scheduler, cache, retries and telemetry unchanged above it); there
|
|
@@ -735,17 +781,14 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
735
781
|
process.stderr.write(`vx run: ${summary.refused}\n`)
|
|
736
782
|
return 1
|
|
737
783
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
// meter bar and `::group::` command above the table. `--report-file`
|
|
747
|
-
// is the redirect-free form.
|
|
748
|
-
if (parsed.report === 'markdown') process.stdout.write(md)
|
|
784
|
+
if (wantsReport) {
|
|
785
|
+
const md =
|
|
786
|
+
reported?.outcomes === summary.outcomes
|
|
787
|
+
? reported.md
|
|
788
|
+
: formatRunReportMarkdown({
|
|
789
|
+
ok: summary.ok,
|
|
790
|
+
outcomes: summary.outcomes.map(projectOutcome),
|
|
791
|
+
})
|
|
749
792
|
if (parsed.reportFile !== undefined) {
|
|
750
793
|
const target = path.resolve(cwd, parsed.reportFile)
|
|
751
794
|
try {
|
|
@@ -768,5 +811,5 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
768
811
|
}
|
|
769
812
|
}
|
|
770
813
|
}
|
|
771
|
-
return
|
|
814
|
+
return summary.ok ? 0 : 1
|
|
772
815
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
import type { ProjectConfig } from '../config.js'
|
|
27
27
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
28
28
|
import { parseDependencySpec } from '../graph/index.js'
|
|
29
|
-
import { declaresInput } from '../cache/index.js'
|
|
29
|
+
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
30
30
|
import { isUserError, listed, maskedLine, nearest, UserError } from '../util/index.js'
|
|
31
31
|
import {
|
|
32
32
|
claimedAffected,
|
|
@@ -60,12 +60,15 @@ export async function workspaceGlobOwners(
|
|
|
60
60
|
load: CliLoadOptions = {},
|
|
61
61
|
stagedLoad: () => Promise<ReadonlyMap<string, ProjectEntry>> = () =>
|
|
62
62
|
loadCliProjects(root, projects, 'all', load),
|
|
63
|
+
/** The changed nested repositories (`AffectedChanges.nested`). */
|
|
64
|
+
nested: readonly string[] = [],
|
|
63
65
|
): Promise<string[]> {
|
|
64
66
|
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
65
67
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
66
68
|
const cache = task.cache
|
|
67
69
|
if (cache === undefined) continue
|
|
68
70
|
if (changed.some((rel) => declaresInput(cache, null, rel))) return true
|
|
71
|
+
if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
|
|
69
72
|
}
|
|
70
73
|
return false
|
|
71
74
|
}
|
|
@@ -319,8 +322,8 @@ export async function resolveFilters(
|
|
|
319
322
|
workspaceRoot: root,
|
|
320
323
|
since: f.gitSince,
|
|
321
324
|
projects,
|
|
322
|
-
workspaceGlobOwners: (changed: readonly string[]) =>
|
|
323
|
-
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
325
|
+
workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
|
|
326
|
+
workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
|
|
324
327
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
325
328
|
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
326
329
|
...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
|
package/src/cli/show.ts
CHANGED
|
@@ -41,7 +41,12 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
41
41
|
else if (a.startsWith('--format=')) format = a.slice('--format='.length)
|
|
42
42
|
else if (a === '--filter') {
|
|
43
43
|
const v = args[++i]
|
|
44
|
-
|
|
44
|
+
// `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
|
|
45
|
+
if (v === undefined || v === '' || v.startsWith('--'))
|
|
46
|
+
return {
|
|
47
|
+
...out,
|
|
48
|
+
error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
|
|
49
|
+
}
|
|
45
50
|
out.filters.push(v)
|
|
46
51
|
} else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
|
|
47
52
|
else if (a === '--affected') out.affected = ''
|
|
@@ -55,7 +60,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
|
|
|
55
60
|
return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
|
|
56
61
|
// An empty name is a part of every name: its "did you mean" listed the
|
|
57
62
|
// whole workspace.
|
|
58
|
-
else if (a === '')
|
|
63
|
+
else if (a === '')
|
|
64
|
+
return { ...out, error: `empty target (omit it to list every project)${seeHelp('show')}` }
|
|
59
65
|
else out.target = a
|
|
60
66
|
|
|
61
67
|
if (format !== undefined) {
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -200,7 +200,7 @@ export async function fetchRelease(tag: string | undefined): Promise<unknown> {
|
|
|
200
200
|
}
|
|
201
201
|
// A body that is not JSON (a captive portal's page, a proxy's error
|
|
202
202
|
// page served with a 200) is the same refusal as a cut one.
|
|
203
|
-
return await readOrRefuse(() => res.json()
|
|
203
|
+
return await readOrRefuse(() => res.json(), url, 'read the release')
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
/**
|
package/src/cli/watch-filter.ts
CHANGED
|
@@ -179,9 +179,14 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
179
179
|
// ignored let a pid file in the same window start cycles again. The
|
|
180
180
|
// refused path is skipped and the rest asked again; a refusal before
|
|
181
181
|
// any record outside a work tree is git refusing them all.
|
|
182
|
-
|
|
182
|
+
// Git refuses a path beyond a symbolic link: its ignored pid file read
|
|
183
|
+
// as an edit and an uncached task re-ran forever. Asked at its real
|
|
184
|
+
// place, answered as given.
|
|
185
|
+
const real = paths.map(gitSpeller(workspaceRoot))
|
|
186
|
+
let from = 0
|
|
183
187
|
let inWorkTree: boolean | undefined
|
|
184
|
-
while (
|
|
188
|
+
while (from < paths.length) {
|
|
189
|
+
const rest = real.slice(from)
|
|
185
190
|
let proc: ReturnType<typeof Bun.spawnSync>
|
|
186
191
|
try {
|
|
187
192
|
proc = Bun.spawnSync({
|
|
@@ -201,8 +206,8 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
201
206
|
const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
|
|
202
207
|
const records = Math.floor(fields.length / 4)
|
|
203
208
|
for (let i = 0; i < records; i++) {
|
|
204
|
-
const [source, , pattern
|
|
205
|
-
if (source !== '' && !pattern!.startsWith('!')) ignored.add(
|
|
209
|
+
const [source, , pattern] = fields.slice(i * 4, i * 4 + 4)
|
|
210
|
+
if (source !== '' && !pattern!.startsWith('!')) ignored.add(paths[from + i]!)
|
|
206
211
|
}
|
|
207
212
|
// 0: some ignored; 1: none. Anything else is git refusing.
|
|
208
213
|
if (proc.exitCode === 0 || proc.exitCode === 1) return ignored
|
|
@@ -216,7 +221,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
|
|
|
216
221
|
}).exitCode === 0
|
|
217
222
|
if (!inWorkTree) return ignored
|
|
218
223
|
}
|
|
219
|
-
|
|
224
|
+
from += records + 1
|
|
220
225
|
}
|
|
221
226
|
return ignored
|
|
222
227
|
}
|
|
@@ -351,19 +356,59 @@ export function isWorkspaceConfigFile(name: string): boolean {
|
|
|
351
356
|
*/
|
|
352
357
|
const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
353
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Paths as git spells them under `root`: a project reached through a
|
|
361
|
+
* symlink below the root (`packages/x -> ../shared/x`) is watched at the
|
|
362
|
+
* link, and git names and answers for its files only at the real place.
|
|
363
|
+
* The root keeps its own spelling (macOS's `/var` is `/private/var`), the
|
|
364
|
+
* name its own (the file may be gone); a directory outside the root is
|
|
365
|
+
* kept as given. Directories are resolved once per speller.
|
|
366
|
+
*/
|
|
367
|
+
export function gitSpeller(root: string): (p: string) => string {
|
|
368
|
+
const dirs = new Map<string, string>()
|
|
369
|
+
let realRoot: string | undefined
|
|
370
|
+
return (p) => {
|
|
371
|
+
const dir = path.dirname(p)
|
|
372
|
+
let at = dirs.get(dir)
|
|
373
|
+
if (at === undefined) {
|
|
374
|
+
at = dir
|
|
375
|
+
try {
|
|
376
|
+
realRoot ??= realPath(root)
|
|
377
|
+
const rel = path.relative(realRoot, realPath(dir))
|
|
378
|
+
if (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)) {
|
|
379
|
+
at = path.join(root, rel)
|
|
380
|
+
}
|
|
381
|
+
} catch {}
|
|
382
|
+
dirs.set(dir, at)
|
|
383
|
+
}
|
|
384
|
+
return path.join(at, path.basename(p))
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
354
388
|
/**
|
|
355
389
|
* The files under the workspace git lists, tracked and untracked, ignored
|
|
356
390
|
* ones aside, and every directory above one (git lists no directory, and
|
|
357
391
|
* a project moved away whole is one event on its directory): what existed
|
|
358
392
|
* when watch armed, so a path born and gone since is told from a deletion
|
|
359
|
-
* (watch-judge.ts).
|
|
360
|
-
*
|
|
393
|
+
* (watch-judge.ts). `tracked` is the files git tracks: a user edits those,
|
|
394
|
+
* so a server cannot be blamed for one. Undefined when git cannot answer:
|
|
395
|
+
* no inventory, every gone path is a deletion as before.
|
|
361
396
|
*/
|
|
362
|
-
export function gitFiles(
|
|
397
|
+
export function gitFiles(
|
|
398
|
+
workspaceRoot: string,
|
|
399
|
+
): { listed: Set<string>; tracked: Set<string> } | undefined {
|
|
363
400
|
let proc: ReturnType<typeof Bun.spawnSync>
|
|
364
401
|
try {
|
|
365
402
|
proc = Bun.spawnSync({
|
|
366
|
-
cmd: [
|
|
403
|
+
cmd: [
|
|
404
|
+
executablePath('git'),
|
|
405
|
+
'ls-files',
|
|
406
|
+
'-z',
|
|
407
|
+
'-t',
|
|
408
|
+
'--cached',
|
|
409
|
+
'--others',
|
|
410
|
+
'--exclude-standard',
|
|
411
|
+
],
|
|
367
412
|
cwd: workspaceRoot,
|
|
368
413
|
stdout: 'pipe',
|
|
369
414
|
stderr: 'ignore',
|
|
@@ -373,13 +418,19 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
|
|
|
373
418
|
}
|
|
374
419
|
if (proc.exitCode !== 0) return undefined
|
|
375
420
|
const files = new Set<string>()
|
|
376
|
-
|
|
377
|
-
|
|
421
|
+
const tracked = new Set<string>()
|
|
422
|
+
for (const entry of new TextDecoder('utf-8', { ignoreBOM: true })
|
|
423
|
+
.decode(proc.stdout)
|
|
424
|
+
.split('\0')) {
|
|
425
|
+
if (entry.length === 0) continue
|
|
426
|
+
// `-t` prefixes a tag and a space; `?` is untracked.
|
|
427
|
+
const p = entry.slice(2)
|
|
378
428
|
// An untracked nested repository is listed as `dir/`.
|
|
379
429
|
let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
|
|
380
430
|
files.add(abs)
|
|
431
|
+
if (entry[0] !== '?') tracked.add(abs)
|
|
381
432
|
for (abs = path.dirname(abs); abs !== workspaceRoot && !files.has(abs); abs = path.dirname(abs))
|
|
382
433
|
files.add(abs)
|
|
383
434
|
}
|
|
384
|
-
return files
|
|
435
|
+
return { listed: files, tracked }
|
|
385
436
|
}
|
package/src/cli/watch-fs.ts
CHANGED
|
@@ -349,7 +349,9 @@ export function modifiedBefore(abs: string, t: number): boolean {
|
|
|
349
349
|
// carry a file's OLD mtime onto the new one, and judged by mtime alone
|
|
350
350
|
// such an edit was "before the arm" and never ran (item 945). No
|
|
351
351
|
// process can set a ctime, and a rename or a write moves it.
|
|
352
|
-
|
|
352
|
+
// The link's own times, not its target's: a link made after the arm
|
|
353
|
+
// to an old file is a new input, and followed it read as the target's.
|
|
354
|
+
const st = fs.lstatSync(abs)
|
|
353
355
|
return Math.max(st.mtimeMs, st.ctimeMs) < t
|
|
354
356
|
} catch {
|
|
355
357
|
return false
|
|
@@ -398,6 +400,10 @@ export class WatcherPool {
|
|
|
398
400
|
// network mount, a container bind — the attempt costs a denied syscall
|
|
399
401
|
// and a two-second wait before the fallback takes over anyway.
|
|
400
402
|
private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
|
|
403
|
+
// The stop closes the pool while a cycle may still be re-arming: an arm
|
|
404
|
+
// after it, or a proof that fails because it closed the watcher, would
|
|
405
|
+
// leave a live watcher or poller nothing ever closes.
|
|
406
|
+
private closed = false
|
|
401
407
|
|
|
402
408
|
constructor(private readonly skip: (dir: string, rel: string) => boolean) {
|
|
403
409
|
if (this.forcePoll)
|
|
@@ -411,6 +417,7 @@ export class WatcherPool {
|
|
|
411
417
|
// By slot, not by handle: an OS watcher that never proves delivery is
|
|
412
418
|
// swapped for a poller in place, and a drop must close what is there.
|
|
413
419
|
arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
|
|
420
|
+
if (this.closed) return CLOSED
|
|
414
421
|
const watchers = this.watchers
|
|
415
422
|
const at = watchers.length
|
|
416
423
|
const handle: WatchHandle = {
|
|
@@ -442,8 +449,8 @@ export class WatcherPool {
|
|
|
442
449
|
armed.ready.then((ok) => {
|
|
443
450
|
if (ok) return
|
|
444
451
|
armed.watcher.close()
|
|
445
|
-
// Dropped by a rearm before its proof settled: nothing to swap in.
|
|
446
|
-
if (watchers[at] !== armed.watcher) return
|
|
452
|
+
// Dropped by a rearm or the stop before its proof settled: nothing to swap in.
|
|
453
|
+
if (this.closed || watchers[at] !== armed.watcher) return
|
|
447
454
|
// The watcher never proved delivery, so it is not one: an FSEvents
|
|
448
455
|
// stream the OS refused, a filesystem that reports nothing. Swap in
|
|
449
456
|
// the poller rather than run a loop that silently never fires.
|
|
@@ -462,6 +469,7 @@ export class WatcherPool {
|
|
|
462
469
|
}
|
|
463
470
|
|
|
464
471
|
closeAll(): void {
|
|
472
|
+
this.closed = true
|
|
465
473
|
for (const w of this.watchers) {
|
|
466
474
|
try {
|
|
467
475
|
w.close()
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import { xxh3 } from '../util/index.js'
|
|
8
|
-
import { gitIgnored } from './watch-filter.js'
|
|
8
|
+
import { gitIgnored, gitSpeller } from './watch-filter.js'
|
|
9
9
|
import { modifiedBefore } from './watch-fs.js'
|
|
10
10
|
|
|
11
11
|
const ABSENT = -1n
|
|
@@ -63,6 +63,8 @@ export interface JudgeContext {
|
|
|
63
63
|
fenced?(ownDir: string, abs: string): boolean
|
|
64
64
|
/** The files git listed at the arm; absent when git could not answer. */
|
|
65
65
|
existedAtArm?: ReadonlySet<string>
|
|
66
|
+
/** The files git tracked at the arm; absent when git could not answer. */
|
|
67
|
+
trackedAtArm?: ReadonlySet<string>
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export class ChangeJudge {
|
|
@@ -124,14 +126,14 @@ export class ChangeJudge {
|
|
|
124
126
|
// the moment between a judgement and that cycle's keys, and gone by the
|
|
125
127
|
// next judgement, was read and its deletion re-runs nothing. Git lists
|
|
126
128
|
// no ignored path, so one of those is a deletion as before.
|
|
127
|
-
private sameState(abs: string, ignored
|
|
129
|
+
private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
|
|
128
130
|
const state = settledState(abs)
|
|
129
131
|
const prev = this.lastState.get(abs)
|
|
130
132
|
this.lastState.set(abs, state)
|
|
131
133
|
if (prev !== undefined) return prev === state
|
|
132
134
|
if (state === ABSENT) {
|
|
133
135
|
const listed = this.ctx.existedAtArm
|
|
134
|
-
return !ignored && listed !== undefined && !listed.has(abs) && !this.inNestedRepo(abs)
|
|
136
|
+
return !ignored && listed !== undefined && !listed.has(spell(abs)) && !this.inNestedRepo(abs)
|
|
135
137
|
}
|
|
136
138
|
return modifiedBefore(abs, this.ctx.armedAt)
|
|
137
139
|
}
|
|
@@ -146,13 +148,17 @@ export class ChangeJudge {
|
|
|
146
148
|
return false
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
|
|
151
|
+
/** `spell` (git's spelling, see `gitSpeller`) given: a held server's write counts outside a cycle too. */
|
|
152
|
+
private writtenDuringLastCycle(abs: string, spell?: (p: string) => string): boolean {
|
|
150
153
|
// A server the last cycle left running is still that cycle's: its
|
|
151
154
|
// writes land after the cycle ended, and with a closed window a dev
|
|
152
155
|
// server that rewrites a log in its project restarted itself forever
|
|
153
156
|
// with no word of it, 12 restarts in 8 s (item 948). The initial run's
|
|
154
|
-
// server is one too, from the arm on.
|
|
155
|
-
|
|
157
|
+
// server is one too, from the arm on. Not a tracked file: that is the
|
|
158
|
+
// user's edit, and three saves of one source file were told to
|
|
159
|
+
// .gitignore it as a server's write (WD-7).
|
|
160
|
+
const open =
|
|
161
|
+
spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
|
|
156
162
|
if (this.lastCycle === undefined && !open) return false
|
|
157
163
|
try {
|
|
158
164
|
const m = fs.statSync(abs).mtimeMs
|
|
@@ -165,6 +171,7 @@ export class ChangeJudge {
|
|
|
165
171
|
|
|
166
172
|
judge(): string | undefined {
|
|
167
173
|
const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
|
|
174
|
+
const spell = gitSpeller(this.ctx.workspaceRoot)
|
|
168
175
|
let first: string | undefined
|
|
169
176
|
let firstAbs: string | undefined
|
|
170
177
|
// `sameState` before the `first` test, never after it: it is what
|
|
@@ -190,7 +197,7 @@ export class ChangeJudge {
|
|
|
190
197
|
let gone: [label: string, abs: string] | undefined
|
|
191
198
|
for (const [p, l] of this.pending) {
|
|
192
199
|
if (ignored.has(p) && !editForUncached(p)) continue
|
|
193
|
-
if (this.sameState(p, ignored.has(p)) || first !== undefined) continue
|
|
200
|
+
if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
|
|
194
201
|
if (!fs.existsSync(p)) {
|
|
195
202
|
gone ??= [l, p]
|
|
196
203
|
continue
|
|
@@ -201,7 +208,7 @@ export class ChangeJudge {
|
|
|
201
208
|
if (first === undefined && gone !== undefined) [first, firstAbs] = gone
|
|
202
209
|
this.pending.clear()
|
|
203
210
|
const byServer = firstAbs !== undefined && this.ctx.held()
|
|
204
|
-
if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs,
|
|
211
|
+
if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
|
|
205
212
|
this.streak.n = 0
|
|
206
213
|
return first
|
|
207
214
|
}
|