@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// What a HIT leaves behind: the outputs of a confirmed cache entry
|
|
2
|
+
// materialised in the project — or proved already current and left alone —
|
|
3
|
+
// the exact changed paths marked so a same-project consumer need not
|
|
4
|
+
// re-spawn git, the stored stdout replayed, and the cache-hit outcome
|
|
5
|
+
// built. There is ONE restore path: `executeCachedTask` and the local
|
|
6
|
+
// short-circuit (a stable hit restored ahead of its deps) both come here,
|
|
7
|
+
// so the two cannot drift. Stale-hit-critical: a line moved here changes
|
|
8
|
+
// which bytes a green run replays. Moved out of execute-task.ts
|
|
9
|
+
// 2026-09-10 as pure code motion, the mirror of miss-save.ts.
|
|
10
|
+
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import type { CacheConfig } from '../config.js'
|
|
13
|
+
import {
|
|
14
|
+
type CacheEntry,
|
|
15
|
+
cleanOutputPaths,
|
|
16
|
+
cleanOutputs,
|
|
17
|
+
cleanWorkspaceOutputs,
|
|
18
|
+
resolveOutputs,
|
|
19
|
+
resolveWorkspaceOutputs,
|
|
20
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
21
|
+
} from '../cache/index.js'
|
|
22
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
23
|
+
import { asTrees, span, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
|
|
24
|
+
import type { ExecuteArgs } from './execute-task.js'
|
|
25
|
+
|
|
26
|
+
export interface RestoreHitArgs {
|
|
27
|
+
args: ExecuteArgs
|
|
28
|
+
hash: string
|
|
29
|
+
hit: CacheEntry
|
|
30
|
+
/** `performance.now()` when the cache op (probe) started — the
|
|
31
|
+
* user-perceived restore duration is measured from here. */
|
|
32
|
+
cacheOpStart: number
|
|
33
|
+
/** ns offset from run start for the outcome's wallclock window. */
|
|
34
|
+
taskStartNs: bigint
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Materialize a confirmed cache hit: decide skip-restore, clean +
|
|
39
|
+
* restore declared outputs, mark the exact changed paths so downstream
|
|
40
|
+
* same-project tasks needn't re-spawn git, replay stored stdout, and
|
|
41
|
+
* build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
|
|
42
|
+
* so the local short-circuit can restore a stable-key hit ahead of the
|
|
43
|
+
* schedule using the IDENTICAL logic — there is one restore path, not
|
|
44
|
+
* two that could drift.
|
|
45
|
+
*/
|
|
46
|
+
export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
|
|
47
|
+
const { args, hash, hit, cacheOpStart, taskStartNs } = restore
|
|
48
|
+
const { node, log } = args
|
|
49
|
+
const cacheCfg: CacheConfig | undefined = node.config.cache
|
|
50
|
+
const outputs = cacheCfg?.outputs.files ?? []
|
|
51
|
+
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
52
|
+
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
53
|
+
const dirPrefixes = wholeSubtreePrefixes(outputs)
|
|
54
|
+
const cleanArgs = {
|
|
55
|
+
projectDir: node.projectDir,
|
|
56
|
+
outputs,
|
|
57
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
58
|
+
}
|
|
59
|
+
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
60
|
+
// The two sides of an overlapping pair (item 588). An ADDITIVE task owns
|
|
61
|
+
// only its recorded rows: its glob also selects the upstream's files it
|
|
62
|
+
// adds beside, so "no strays" is not a claim it can make, and its clean
|
|
63
|
+
// takes the rows, never the glob. The UPSTREAM ignores what its
|
|
64
|
+
// dependants declare they add when it judges its own tree current — an
|
|
65
|
+
// addition below is not a stray — while its glob clean still takes them:
|
|
66
|
+
// the dependant restores or runs after it, by the edge.
|
|
67
|
+
const additive = (node.addsToOutputsOf?.length ?? 0) > 0
|
|
68
|
+
const addedGlobs = (node.outputsAddedToBy ?? [])
|
|
69
|
+
.flatMap((g) => asTrees([g]))
|
|
70
|
+
.map((g) => taskGlob(g))
|
|
71
|
+
const isAddition = (rel: string): boolean => addedGlobs.some((g) => g.match(rel))
|
|
72
|
+
|
|
73
|
+
// "Tree is already current" short-circuit — skip cleanOutputs
|
|
74
|
+
// + restoreOutputs when the on-disk state matches what this
|
|
75
|
+
// entry recorded at save time. Integrity-preserving: we
|
|
76
|
+
// require BOTH that the output-glob walk yields exactly the
|
|
77
|
+
// expected paths (no strays, no missing) AND that every file's
|
|
78
|
+
// (size, mode, mtime) matches the stored fingerprint. Any
|
|
79
|
+
// divergence falls through to a real clean + restore.
|
|
80
|
+
//
|
|
81
|
+
// The output-file fingerprints can't be batch-loaded at
|
|
82
|
+
// prepareRun time — non-leaf task hashes depend on upstream
|
|
83
|
+
// outputs that haven't been written yet, so hashes are
|
|
84
|
+
// necessarily computed mid-run. The short-circuit's batched probe
|
|
85
|
+
// loads the rows with the entry (`hit.outputRows`); only the lazy
|
|
86
|
+
// path pays one SELECT here. Either beats reading the manifest from
|
|
87
|
+
// the tar (decompress + parse) at the same point.
|
|
88
|
+
// A task with no declared outputs has nothing to clean or restore: its
|
|
89
|
+
// stdout replays from the row (`hit.stdout`), and its artifact holds
|
|
90
|
+
// only logs. Extracting it anyway cost an `exists` + a tar read per hit
|
|
91
|
+
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
92
|
+
// tasks declare `outputs: []` (2026-09-10, VX_TIMING).
|
|
93
|
+
let skipRestore = !anyOutputs
|
|
94
|
+
if (anyOutputs) {
|
|
95
|
+
const endRows = span('output rows')
|
|
96
|
+
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
97
|
+
endRows()
|
|
98
|
+
if (expected.length > 0) {
|
|
99
|
+
// Two namespaces in the rows: bare rels are project outputs,
|
|
100
|
+
// `workspace-outputs/<rel>` rows anchor at the workspace root.
|
|
101
|
+
const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
102
|
+
const wsExpected = expected
|
|
103
|
+
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
104
|
+
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
105
|
+
// The directory short-circuit: for whole-subtree globs, unchanged
|
|
106
|
+
// mtimes on every directory recorded at the last save/restore prove
|
|
107
|
+
// the output SET is unchanged (a file added or removed anywhere the
|
|
108
|
+
// glob could see bumps a recorded directory), so the walk that cost
|
|
109
|
+
// 0.36 ms per warm hit is replaced by a few stats. The per-file
|
|
110
|
+
// fingerprint check below still runs; only the enumeration is skipped.
|
|
111
|
+
let setKnown = false
|
|
112
|
+
if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
|
|
113
|
+
const endDirs = span('output dirs')
|
|
114
|
+
// Loaded with the entry (batched by getMany, or with the lazy get).
|
|
115
|
+
const dirRows = hit.outputDirRows ?? []
|
|
116
|
+
// Rows are recorded for these same prefixes under this same key
|
|
117
|
+
// (the outputs are folded into it), so `covers` cannot be false
|
|
118
|
+
// today — deleting it reddens nothing (item 638). Kept: a row
|
|
119
|
+
// set that shrank would otherwise vouch for a tree it never saw,
|
|
120
|
+
// and the failure it would let through is a skipped restore.
|
|
121
|
+
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
122
|
+
setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
|
|
123
|
+
endDirs()
|
|
124
|
+
}
|
|
125
|
+
let actualAbs: string[]
|
|
126
|
+
let actualWsAbs: string[]
|
|
127
|
+
if (setKnown) {
|
|
128
|
+
actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
|
|
129
|
+
actualWsAbs = []
|
|
130
|
+
} else {
|
|
131
|
+
const endGlob = span('output glob')
|
|
132
|
+
actualAbs = await resolveOutputs({
|
|
133
|
+
projectDir: node.projectDir,
|
|
134
|
+
outputs,
|
|
135
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
136
|
+
})
|
|
137
|
+
actualWsAbs = await resolveWorkspaceOutputs({
|
|
138
|
+
workspaceRoot: args.workspaceRoot,
|
|
139
|
+
outputs: wsOutputs,
|
|
140
|
+
})
|
|
141
|
+
endGlob()
|
|
142
|
+
}
|
|
143
|
+
const setsMatch = (
|
|
144
|
+
actual: readonly string[],
|
|
145
|
+
exp: ReadonlyArray<{ path: string }>,
|
|
146
|
+
): boolean => {
|
|
147
|
+
const expSet = new Set(exp.map((e) => e.path))
|
|
148
|
+
return actual.length === expSet.size && actual.every((r) => expSet.has(r))
|
|
149
|
+
}
|
|
150
|
+
// A stray a dependant's glob could have added is not a stray; a path
|
|
151
|
+
// this entry recorded is never dropped, even where that glob covers
|
|
152
|
+
// the whole tree (strapi's shape: both on `dist`).
|
|
153
|
+
const expectedRels = new Set(projExpected.map((e) => e.path))
|
|
154
|
+
const expectedWsRels = new Set(wsExpected.map((e) => e.path))
|
|
155
|
+
const actualRels = actualAbs
|
|
156
|
+
.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/'))
|
|
157
|
+
.filter((rel) => expectedRels.has(rel) || !isAddition(rel))
|
|
158
|
+
const actualWsRels = actualWsAbs
|
|
159
|
+
.map((p) => path.relative(args.workspaceRoot, p).split(path.sep).join('/'))
|
|
160
|
+
.filter((rel) => expectedWsRels.has(rel) || !isAddition(rel))
|
|
161
|
+
const rowsPresent = (
|
|
162
|
+
actual: readonly string[],
|
|
163
|
+
exp: ReadonlyArray<{ path: string }>,
|
|
164
|
+
): boolean => {
|
|
165
|
+
const have = new Set(actual)
|
|
166
|
+
return exp.every((e) => have.has(e.path))
|
|
167
|
+
}
|
|
168
|
+
const treeMatches = additive
|
|
169
|
+
? rowsPresent(actualRels, projExpected) && rowsPresent(actualWsRels, wsExpected)
|
|
170
|
+
: setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)
|
|
171
|
+
if (treeMatches) {
|
|
172
|
+
const endStat = span('output stat')
|
|
173
|
+
skipRestore =
|
|
174
|
+
(await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
|
|
175
|
+
(await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
|
|
176
|
+
endStat()
|
|
177
|
+
// The walk ran and proved the tree current (rows absent, or a
|
|
178
|
+
// directory had moved — a benign touch): snapshot the directories
|
|
179
|
+
// so the next hit skips the walk.
|
|
180
|
+
if (skipRestore && !setKnown && dirPrefixes !== null) {
|
|
181
|
+
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// An entry with NO rows (its declared outputs matched nothing at save
|
|
187
|
+
// time, the warned case) is current exactly when the globs still match
|
|
188
|
+
// nothing: then the empty artifact has nothing to put back and the read
|
|
189
|
+
// of the tar is spared. A stray under the glob still falls through to
|
|
190
|
+
// the clean, as strict ownership requires (item 589).
|
|
191
|
+
if (anyOutputs && !skipRestore) {
|
|
192
|
+
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
193
|
+
if (expected.length === 0) {
|
|
194
|
+
const endGlob = span('output glob')
|
|
195
|
+
const actual = await resolveOutputs({
|
|
196
|
+
projectDir: node.projectDir,
|
|
197
|
+
outputs,
|
|
198
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
199
|
+
})
|
|
200
|
+
const actualWs = await resolveWorkspaceOutputs({
|
|
201
|
+
workspaceRoot: args.workspaceRoot,
|
|
202
|
+
outputs: wsOutputs,
|
|
203
|
+
})
|
|
204
|
+
endGlob()
|
|
205
|
+
skipRestore = actual.length === 0 && actualWs.length === 0
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!skipRestore) {
|
|
209
|
+
let cleanedRels: string[] = []
|
|
210
|
+
let cleanedWsRels: string[] = []
|
|
211
|
+
if (additive) {
|
|
212
|
+
cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
213
|
+
await cleanOutputPaths({ projectDir: node.projectDir, rels: cleanedRels })
|
|
214
|
+
cleanedWsRels = hit.outputFiles
|
|
215
|
+
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
216
|
+
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
217
|
+
await cleanOutputPaths({ projectDir: args.workspaceRoot, rels: cleanedWsRels })
|
|
218
|
+
} else {
|
|
219
|
+
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
220
|
+
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
221
|
+
}
|
|
222
|
+
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
223
|
+
// The tree now holds this entry's bytes: stamp them for the next hit
|
|
224
|
+
// (item 886).
|
|
225
|
+
args.cache.recordOutputStamps?.(hash, node.projectDir, args.workspaceRoot)
|
|
226
|
+
// The directory snapshot behind the NEXT hit's skip-restore, taken at
|
|
227
|
+
// run end from the run's list (as the miss path does, see
|
|
228
|
+
// miss-save.ts): the restore renamed into these directories
|
|
229
|
+
// microseconds ago, inside the snapshot's racy window, so a snapshot
|
|
230
|
+
// taken here was refused and the first warm run after every restore
|
|
231
|
+
// walked its output trees — 41 of payload's 45 tasks, 14,430 files,
|
|
232
|
+
// ~75 ms of the no-op's CPU (2026-09-11). A caller with no list gets
|
|
233
|
+
// no snapshot (item 637: recording here was that same refusal).
|
|
234
|
+
if (dirPrefixes !== null && args.outputDirSnapshots !== undefined) {
|
|
235
|
+
args.outputDirSnapshots.push({ hash, projectDir: node.projectDir, prefixes: dirPrefixes })
|
|
236
|
+
}
|
|
237
|
+
// Restored outputs changed the project's tree — but on this
|
|
238
|
+
// path we know the EXACT changed paths (wiped declared
|
|
239
|
+
// outputs + the artifact's files). Record them instead of
|
|
240
|
+
// dropping the snapshot; downstream same-project tasks only
|
|
241
|
+
// re-spawn git when their input globs can actually see one of
|
|
242
|
+
// these paths. The cache-miss save path keeps the
|
|
243
|
+
// unconditional drop (an executed task may write undeclared
|
|
244
|
+
// files only git can see).
|
|
245
|
+
if (outputs.length > 0) {
|
|
246
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
247
|
+
...cleanedRels,
|
|
248
|
+
...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
249
|
+
])
|
|
250
|
+
}
|
|
251
|
+
if (wsOutputs.length > 0) {
|
|
252
|
+
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
253
|
+
...cleanedWsRels,
|
|
254
|
+
...hit.outputFiles
|
|
255
|
+
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
256
|
+
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
257
|
+
])
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (hit.stdout) log.taskStdout(node, hit.stdout)
|
|
261
|
+
const status =
|
|
262
|
+
hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
|
|
263
|
+
// `restored` distinguishes "we just wrote files to disk" from
|
|
264
|
+
// "disk already matched the cached snapshot". Drives the
|
|
265
|
+
// "up-to-date" vs "local-cache" / "remote-cache" label in the
|
|
266
|
+
// framed block. Only meaningful when at least one output was
|
|
267
|
+
// declared — no-outputs tasks never materialize anything, so
|
|
268
|
+
// they're vacuously up-to-date.
|
|
269
|
+
const restored = !skipRestore && anyOutputs
|
|
270
|
+
return {
|
|
271
|
+
node,
|
|
272
|
+
status,
|
|
273
|
+
exitCode: hit.exitCode,
|
|
274
|
+
// What THIS run spent: probe + restore. NOT the stored exec time — see
|
|
275
|
+
// `storedDurationMs`, which is what the hit skipped.
|
|
276
|
+
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
277
|
+
storedDurationMs: hit.durationMs,
|
|
278
|
+
...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
|
|
279
|
+
...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
|
|
280
|
+
hash,
|
|
281
|
+
restored,
|
|
282
|
+
wallclockStartNs: taskStartNs,
|
|
283
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -3,12 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
export { run, planRun } from './run.js'
|
|
5
5
|
export { prepareRun, type PreparedRun } from './prepare.js'
|
|
6
|
+
export {
|
|
7
|
+
loadProjects,
|
|
8
|
+
loadResolvedProjects,
|
|
9
|
+
loadWorkspacePlugins,
|
|
10
|
+
type LoadedProjects,
|
|
11
|
+
type LoadProjectsArgs,
|
|
12
|
+
} from './projects.js'
|
|
6
13
|
export { computeTaskHash, createHashCache, type HashCache } from './task-hash.js'
|
|
7
|
-
export type { RunOptions, RunSummary } from './options.js'
|
|
14
|
+
export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
|
|
8
15
|
// Re-surface the cache policy contract (defined in the cache module) so
|
|
9
16
|
// embedders constructing RunOptions.cache and the package façade can
|
|
10
17
|
// reach it without importing the cache module directly.
|
|
11
18
|
export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
19
|
+
// The CLI reads a recorded exit code; the runner's convention decodes it.
|
|
20
|
+
export { exitSignal } from '../exec/index.js'
|
|
21
|
+
export { forwardedSignal } from './signals.js'
|
|
22
|
+
// `vx cache prune` waits for a run on the workspace before it evicts.
|
|
23
|
+
export { acquireRunLock } from './run-lock.js'
|
|
12
24
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
13
25
|
export type { Logger, OutputView } from './logger.js'
|
|
14
26
|
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
@@ -19,6 +31,8 @@ export {
|
|
|
19
31
|
toWireEvent,
|
|
20
32
|
projectNode,
|
|
21
33
|
projectOutcome,
|
|
34
|
+
outcomeWord,
|
|
35
|
+
outcomeLabel,
|
|
22
36
|
} from './events.js'
|
|
23
37
|
export type {
|
|
24
38
|
EventBus,
|
|
@@ -36,6 +50,14 @@ export {
|
|
|
36
50
|
LocalHistoryProvider,
|
|
37
51
|
type TaskHistory,
|
|
38
52
|
} from './history.js'
|
|
53
|
+
export {
|
|
54
|
+
detectFlaky,
|
|
55
|
+
type FailureMode,
|
|
56
|
+
type FlakyCandidate,
|
|
57
|
+
type FlakyFinding,
|
|
58
|
+
type FlakyTask,
|
|
59
|
+
flakyTasks,
|
|
60
|
+
} from './failure-mode.js'
|
|
39
61
|
export {
|
|
40
62
|
installPlugins,
|
|
41
63
|
type CacheContext,
|
|
@@ -46,6 +68,7 @@ export {
|
|
|
46
68
|
type PluginCommand,
|
|
47
69
|
type ProjectHookContext,
|
|
48
70
|
type ScheduleHookContext,
|
|
71
|
+
type AdmitContext,
|
|
49
72
|
type WorkspaceHookContext,
|
|
50
73
|
type InstallPluginsArgs,
|
|
51
74
|
type Plugin,
|
|
@@ -53,7 +76,13 @@ export {
|
|
|
53
76
|
type PluginHookHandlers,
|
|
54
77
|
type PluginHookName,
|
|
55
78
|
type PluginSetupContext,
|
|
79
|
+
type FingerprintChange,
|
|
80
|
+
type FingerprintClaim,
|
|
81
|
+
type FingerprintContext,
|
|
56
82
|
type VxPlugin,
|
|
83
|
+
definePlugin,
|
|
84
|
+
type PluginHooks,
|
|
85
|
+
type PluginOrigin,
|
|
57
86
|
} from './plugin.js'
|
|
58
87
|
export {
|
|
59
88
|
applyConfigHooks,
|
|
@@ -61,11 +90,20 @@ export {
|
|
|
61
90
|
applyKeyHooks,
|
|
62
91
|
applyProjectHooks,
|
|
63
92
|
applyScheduleHooks,
|
|
93
|
+
claimedAffected,
|
|
94
|
+
fingerprintClaims,
|
|
64
95
|
hasHook,
|
|
96
|
+
CACHE_LAYER_METHODS,
|
|
65
97
|
resolveCache,
|
|
66
98
|
resolveExecutors,
|
|
67
99
|
} from './plugin-host.js'
|
|
68
|
-
export {
|
|
100
|
+
export {
|
|
101
|
+
lockfileClaim,
|
|
102
|
+
reachDigests,
|
|
103
|
+
type LockfileClaimHooks,
|
|
104
|
+
type LockfileClaimOptions,
|
|
105
|
+
type ReachGraph,
|
|
106
|
+
} from './lockfile-claim.js'
|
|
69
107
|
export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
70
108
|
// The bounded log-capture buffer every telemetry sink shares — see the
|
|
71
109
|
// module header for why one implementation, not one per sink.
|
|
@@ -90,9 +128,6 @@ export type {
|
|
|
90
128
|
TelemetrySink,
|
|
91
129
|
TelemetrySource,
|
|
92
130
|
} from './telemetry.js'
|
|
93
|
-
// `diffOutputTrees` is the ONE tree-diff implementation — the verify verdict
|
|
94
|
-
// and a serve's cross-machine fingerprint diff must never drift on it.
|
|
95
|
-
export { diffOutputTrees } from './verify.js'
|
|
96
131
|
export { deriveStableKeys } from './stable-keys.js'
|
|
97
132
|
export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
|
|
98
133
|
export {
|
|
@@ -108,6 +143,7 @@ export {
|
|
|
108
143
|
explainCacheKey as explainCacheKeyQuery,
|
|
109
144
|
getInvocation,
|
|
110
145
|
getRun,
|
|
146
|
+
latestRunId,
|
|
111
147
|
listInvocations,
|
|
112
148
|
listRuns,
|
|
113
149
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
@@ -125,3 +161,4 @@ export type {
|
|
|
125
161
|
RunSummaryRow,
|
|
126
162
|
WhyDidThisRerun,
|
|
127
163
|
} from './metrics.js'
|
|
164
|
+
export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The projects a task's cache key answers for: K(T) in
|
|
2
|
+
// docs/design/linked-sibling-reads-2026-09.md. A sandboxed task that
|
|
3
|
+
// declares `cache` is granted a sibling's `node_modules` link only when its
|
|
4
|
+
// key already moves with that sibling's files, and this is where "already"
|
|
5
|
+
// is decided — on the graph, before any hash exists, through the same
|
|
6
|
+
// selection the hash path applies (`selectFoldedDeps`).
|
|
7
|
+
|
|
8
|
+
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
9
|
+
import { selectFoldedDeps, type FoldCandidate } from './upstream.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A run's keyed-set lookup: for a task, the directories of every project
|
|
13
|
+
* holding an exec task whose key the task's key folds, transitively. The
|
|
14
|
+
* task itself is not counted.
|
|
15
|
+
*
|
|
16
|
+
* The fold relation is the hash path's, computed on the graph:
|
|
17
|
+
* - an exec task folds its dependencies as its own `cache.inputs.tasks`
|
|
18
|
+
* selects them (absent, or no `cache` at all, means all);
|
|
19
|
+
* - a group folds every dependency (`computeGroupHash`) and folds no
|
|
20
|
+
* files of its own;
|
|
21
|
+
* - a persistent task has no hash on the live path, so it is folded by
|
|
22
|
+
* no one and its own dependencies reach no key through it. (The local
|
|
23
|
+
* classify pass does give it one — stable-keys.ts — so a key there can
|
|
24
|
+
* move with more than this set names; never with less.)
|
|
25
|
+
*
|
|
26
|
+
* Every exec task reached contributes its project, cached or not: a key
|
|
27
|
+
* folds the task's own project files, and a task with no `cache` declares
|
|
28
|
+
* no `inputs.files`, so it folds every file of its project (inputs.ts's
|
|
29
|
+
* `DEFAULT_FILE_GLOBS`). `tests/keyed-projects.test.ts` holds that to the
|
|
30
|
+
* key itself.
|
|
31
|
+
*
|
|
32
|
+
* Lazy and memoized by task id: only a sandboxed task that executes asks,
|
|
33
|
+
* so a run of hits walks nothing, and a shared subgraph is walked once.
|
|
34
|
+
*/
|
|
35
|
+
export function keyedProjects(
|
|
36
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
37
|
+
): (node: TaskNode) => ReadonlySet<string> {
|
|
38
|
+
const below = new Map<string, ReadonlySet<string>>()
|
|
39
|
+
// Post-order on an explicit stack: a fold is as deep as the graph, and a
|
|
40
|
+
// recursion per edge threw `RangeError` at the 50,000 the builder takes
|
|
41
|
+
// (item 737). A frame's folded dependencies are listed when it is first
|
|
42
|
+
// reached and combined once each has its set.
|
|
43
|
+
return (root) => {
|
|
44
|
+
const stack: Array<[node: TaskNode, deps: FoldCandidate[] | undefined]> = [[root, undefined]]
|
|
45
|
+
while (stack.length > 0) {
|
|
46
|
+
const frame = stack[stack.length - 1]!
|
|
47
|
+
const [node, deps] = frame
|
|
48
|
+
if (below.has(node.id)) {
|
|
49
|
+
stack.pop()
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (deps === undefined) {
|
|
53
|
+
frame[1] = folded(node, nodes)
|
|
54
|
+
for (const { node: dep } of frame[1]) if (!below.has(dep.id)) stack.push([dep, undefined])
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
stack.pop()
|
|
58
|
+
const out = new Set<string>()
|
|
59
|
+
for (const { node: dep } of deps) {
|
|
60
|
+
if (!isGroupTask(dep)) out.add(dep.projectDir)
|
|
61
|
+
for (const dir of below.get(dep.id)!) out.add(dir)
|
|
62
|
+
}
|
|
63
|
+
below.set(node.id, out)
|
|
64
|
+
}
|
|
65
|
+
return below.get(root.id)!
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The dependencies `node`'s key folds, per the hash path's rules above. */
|
|
70
|
+
function folded(node: TaskNode, nodes: ReadonlyMap<string, TaskNode>): FoldCandidate[] {
|
|
71
|
+
const candidates: FoldCandidate[] = []
|
|
72
|
+
for (const id of node.deps) {
|
|
73
|
+
const dep = nodes.get(id)!
|
|
74
|
+
if (dep.config.exec?.persistent !== undefined) continue
|
|
75
|
+
candidates.push({ node: dep, unit: foldUnit(dep) })
|
|
76
|
+
}
|
|
77
|
+
if (isGroupTask(node)) return candidates
|
|
78
|
+
return selectFoldedDeps(candidates, node.config.cache?.inputs?.tasks, node.projectName, node.id)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What stands in for a dependency's hash in the dedup: equal exactly when
|
|
83
|
+
* the hashes are. An exec task's key folds its own id (`task:<id>`), so its
|
|
84
|
+
* id is enough; a group's hash is a roll-up of its members' ids and hashes
|
|
85
|
+
* with no id of its own, so two groups over the same members hash alike.
|
|
86
|
+
*/
|
|
87
|
+
function foldUnit(node: TaskNode): string {
|
|
88
|
+
return isGroupTask(node) ? `group|${[...node.deps].sort().join('|')}` : `task|${node.id}`
|
|
89
|
+
}
|
|
@@ -32,16 +32,19 @@
|
|
|
32
32
|
// - Only STABLE-key, cacheable, local-read tasks are classified. A
|
|
33
33
|
// stable miss stays in the normal (dep-gated) schedule; an unstable
|
|
34
34
|
// task is never probed here.
|
|
35
|
-
// -
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
35
|
+
// - A task that declares `cache.outputs.workspaceFiles` (the
|
|
36
|
+
// boundary-ignoring escape hatch) can write where a restore touches,
|
|
37
|
+
// so every task whose project directory a workspace-output glob's
|
|
38
|
+
// static prefix REACHES stays out of the restore tier, and so does
|
|
39
|
+
// every transitive dependant of one (its up-front key folds a key
|
|
40
|
+
// that is preliminary). A glob with no literal prefix reaches every
|
|
41
|
+
// project, which is the old graph-wide rule. Excluded tasks still get
|
|
42
|
+
// an exec-tier probe reuse entry so there's no double work.
|
|
40
43
|
|
|
41
|
-
import { span } from '../util/index.js'
|
|
44
|
+
import { normalizeGlob, relPosix, span, staticPrefix } from '../util/index.js'
|
|
42
45
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
43
46
|
import type { TaskNode } from '../graph/index.js'
|
|
44
|
-
import { deriveStableKeys } from './stable-keys.js'
|
|
47
|
+
import { deriveStableKeys, workspaceInputsReach } from './stable-keys.js'
|
|
45
48
|
import type { HashCache } from './task-hash.js'
|
|
46
49
|
|
|
47
50
|
export interface ShortCircuitArgs {
|
|
@@ -104,22 +107,13 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
104
107
|
} finally {
|
|
105
108
|
endKeys()
|
|
106
109
|
}
|
|
107
|
-
|
|
110
|
+
// Every stable key is a cacheable task's: deriveStableKeys pushes only
|
|
111
|
+
// `cacheEnabled && !unstable` (item 640 deleted a second filter here and
|
|
112
|
+
// nothing reddened).
|
|
113
|
+
const candidates = stableKeys
|
|
108
114
|
if (candidates.length === 0) return EMPTY
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
// root-anchored output can land anywhere, so if ANY task declares
|
|
112
|
-
// workspace outputs a task could in principle write where a
|
|
113
|
-
// restore-tier task restores. Disable the RESTORE tier graph-wide —
|
|
114
|
-
// workspace outputs are rare bad-practice. (Probe reuse still applies,
|
|
115
|
-
// so there's no double work; those tasks just stay dep-gated.)
|
|
116
|
-
let anyWorkspaceOutputs = false
|
|
117
|
-
for (const node of args.nodes.values()) {
|
|
118
|
-
if ((node.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0) {
|
|
119
|
-
anyWorkspaceOutputs = true
|
|
120
|
-
break
|
|
121
|
-
}
|
|
122
|
-
}
|
|
116
|
+
const keptOut = restoreTierExclusions(args.nodes, args.workspaceRoot)
|
|
123
117
|
|
|
124
118
|
const preProbed = new Map<string, ProbedEntry>()
|
|
125
119
|
const restoreTier = new Set<string>()
|
|
@@ -139,7 +133,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
139
133
|
for (const { hash, node } of candidates) {
|
|
140
134
|
const hit = hits.get(hash) ?? null
|
|
141
135
|
preProbed.set(node.id, { hash, hit })
|
|
142
|
-
if (hit !== null && !
|
|
136
|
+
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
143
137
|
}
|
|
144
138
|
return { preProbed, restoreTier }
|
|
145
139
|
} catch {
|
|
@@ -156,7 +150,7 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
156
150
|
const command = node.config.exec?.command ?? ''
|
|
157
151
|
const hit = await args.cache.get(hash, { taskId: node.id, command })
|
|
158
152
|
preProbed.set(node.id, { hash, hit })
|
|
159
|
-
if (hit !== null && !
|
|
153
|
+
if (hit !== null && !keptOut.has(node.id)) restoreTier.add(node.id)
|
|
160
154
|
} catch {
|
|
161
155
|
// Leave this task out of preProbed → it probes lazily in
|
|
162
156
|
// execute(), exactly as today.
|
|
@@ -167,3 +161,69 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
167
161
|
|
|
168
162
|
return { preProbed, restoreTier }
|
|
169
163
|
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The tasks a workspace-output declaration keeps out of the restore tier.
|
|
167
|
+
*
|
|
168
|
+
* A root-anchored output can land in any project's directory, edge or no
|
|
169
|
+
* edge (`tests/local-shortcircuit.test.ts` § "a workspace-output writer"
|
|
170
|
+
* rows, item 425), so the question is not who depends on the writer but
|
|
171
|
+
* WHERE it can write: each declared glob's static prefix is tested for
|
|
172
|
+
* reach against every project directory, and against every task's own
|
|
173
|
+
* `workspaceFiles` inputs, with the ancestor-or-equal relation
|
|
174
|
+
* `workspaceInputsReach` already uses. A prefix that is the root reaches
|
|
175
|
+
* everything — the graph-wide rule this replaced (item 584). Exclusion then
|
|
176
|
+
* follows the edges DOWN: a dependant's up-front key folds an excluded
|
|
177
|
+
* task's key, which is preliminary, so it cannot restore early either.
|
|
178
|
+
* Cost: one pass over the nodes, no filesystem.
|
|
179
|
+
*/
|
|
180
|
+
function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: string): Set<string> {
|
|
181
|
+
const prefixes: string[] = []
|
|
182
|
+
let everything = false
|
|
183
|
+
for (const node of nodes.values()) {
|
|
184
|
+
for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
|
|
185
|
+
// No negation to skip: the schema refuses '!' in output globs
|
|
186
|
+
// (`validateWorkspaceGlobs`), so the `continue` that stood here
|
|
187
|
+
// guarded a shape no config can carry (item 640).
|
|
188
|
+
const glob = normalizeGlob(raw)
|
|
189
|
+
const prefix = staticPrefix(glob)
|
|
190
|
+
if (prefix === '.' || prefix === '' || prefix === '/') everything = true
|
|
191
|
+
else prefixes.push(prefix.replace(/^\.\//, ''))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const out = new Set<string>()
|
|
195
|
+
if (everything) {
|
|
196
|
+
for (const id of nodes.keys()) out.add(id)
|
|
197
|
+
return out
|
|
198
|
+
}
|
|
199
|
+
if (prefixes.length === 0) return out
|
|
200
|
+
const reaches = (dir: string): boolean =>
|
|
201
|
+
dir === '' ||
|
|
202
|
+
dir === '.' ||
|
|
203
|
+
prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
|
|
204
|
+
const dependants = new Map<string, string[]>()
|
|
205
|
+
for (const node of nodes.values()) {
|
|
206
|
+
const dir = relPosix(workspaceRoot, node.projectDir)
|
|
207
|
+
const wsInputs = node.config.cache?.inputs?.workspaceFiles ?? []
|
|
208
|
+
if (reaches(dir) || (wsInputs.length > 0 && workspaceInputsReach(wsInputs, prefixes))) {
|
|
209
|
+
out.add(node.id)
|
|
210
|
+
}
|
|
211
|
+
for (const dep of node.deps) {
|
|
212
|
+
const list = dependants.get(dep)
|
|
213
|
+
if (list === undefined) dependants.set(dep, [node.id])
|
|
214
|
+
else list.push(node.id)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Up the dependant edges on an explicit stack: a chain is as deep as the
|
|
218
|
+
// graph, and a recursion per edge threw `RangeError` at the 50,000 the
|
|
219
|
+
// builder takes (item 737).
|
|
220
|
+
const stack = [...out]
|
|
221
|
+
while (stack.length > 0) {
|
|
222
|
+
for (const up of dependants.get(stack.pop()!) ?? []) {
|
|
223
|
+
if (out.has(up)) continue
|
|
224
|
+
out.add(up)
|
|
225
|
+
stack.push(up)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return out
|
|
229
|
+
}
|