@vzn/vx 0.0.652 → 0.0.653
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 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// plugin `project` stage, which can give tasks to a package that never
|
|
5
5
|
// wrote a `vx.config.ts`.
|
|
6
6
|
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
7
8
|
import { stat } from 'node:fs/promises'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
import type { ProjectConfig } from '../config.js'
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
lockedProjects,
|
|
15
16
|
NO_LOCK,
|
|
16
17
|
pathName,
|
|
18
|
+
PROJECT_CONFIG_FILENAMES,
|
|
17
19
|
readLockfile,
|
|
18
20
|
validateProjectConfig,
|
|
19
21
|
type Lockfile,
|
|
@@ -22,8 +24,9 @@ import {
|
|
|
22
24
|
type ProjectMeta,
|
|
23
25
|
} from '../workspace/index.js'
|
|
24
26
|
import type { WorkspaceConfig } from '../config.js'
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
+
import { atPath, SETTINGS } from '../settings.js'
|
|
28
|
+
import { lazyGitEnumeration, type LazyGitEnumeration, parseCachePolicy } from '../cache/index.js'
|
|
29
|
+
import { checkIgnoreSync, isUserError, relPosix, UserError } from '../util/index.js'
|
|
27
30
|
import {
|
|
28
31
|
cacheScopeError,
|
|
29
32
|
isCacheScope,
|
|
@@ -34,8 +37,10 @@ import {
|
|
|
34
37
|
validateWorkspace,
|
|
35
38
|
} from '../workspace/index.js'
|
|
36
39
|
import { DEFAULT_BUILD, parseDependencySpec } from '../graph/index.js'
|
|
40
|
+
import { workspaceFile } from '../handoff.js'
|
|
37
41
|
import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
|
|
38
42
|
import type { BranchFacts, VxPlugin } from './plugin.js'
|
|
43
|
+
import { freezeDeep, readOnly } from './read-only.js'
|
|
39
44
|
|
|
40
45
|
/**
|
|
41
46
|
* The workspace config with the plugin `config` stage applied — the first
|
|
@@ -49,12 +54,27 @@ export async function loadWorkspacePlugins(
|
|
|
49
54
|
workspaceConfig: WorkspaceConfig | null
|
|
50
55
|
plugins: readonly VxPlugin[]
|
|
51
56
|
branch: BranchFacts
|
|
57
|
+
/** Setting path → the last plugin whose `config` hook changed it (`vx info`). */
|
|
58
|
+
settingPlugins: ReadonlyMap<string, string>
|
|
52
59
|
}> {
|
|
53
60
|
const loaded = await loadWorkspaceConfig(workspaceRoot)
|
|
61
|
+
const policy = loaded?.cache?.policy
|
|
62
|
+
if (policy !== undefined) {
|
|
63
|
+
// The grammar is the cache module's, which the schema may not import:
|
|
64
|
+
// checked here, where the file is known, so the refusal is framed.
|
|
65
|
+
try {
|
|
66
|
+
parseCachePolicy(policy)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
const file = await workspaceFile(workspaceRoot)
|
|
69
|
+
if (!isUserError(err) || file === null) throw err
|
|
70
|
+
throw new UserError(`${file}: \`cache.policy\` ${err.message}`, 'VX_E_CONFIG')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
54
73
|
const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
|
|
55
74
|
const branch: BranchFacts = {}
|
|
75
|
+
const settingPlugins = new Map<string, string>()
|
|
56
76
|
if (loaded === null || !hasHook(plugins, 'config'))
|
|
57
|
-
return { workspaceConfig: loaded, plugins, branch }
|
|
77
|
+
return { workspaceConfig: loaded, plugins, branch, settingPlugins }
|
|
58
78
|
// Bun keeps one module per specifier, so the file's export is one object
|
|
59
79
|
// per process: the CLI's selection pass, the run and each `vx watch`
|
|
60
80
|
// cycle handed the hooks what the last load's hooks had edited
|
|
@@ -91,9 +111,17 @@ export async function loadWorkspacePlugins(
|
|
|
91
111
|
branch.refs = [...new Set(facts.refs)]
|
|
92
112
|
}
|
|
93
113
|
}
|
|
114
|
+
const settingPaths = SETTINGS.filter((s) => s.config !== false).map((s) => s.path)
|
|
115
|
+
const settingValues = new Map(settingPaths.map((p) => [p, atPath(workspaceConfig, p)]))
|
|
94
116
|
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) => {
|
|
95
117
|
const at = `.vx/workspace (after plugin '${plugin.name}')`
|
|
96
118
|
validateWorkspace(workspaceConfig, at)
|
|
119
|
+
for (const p of settingPaths) {
|
|
120
|
+
const v = atPath(workspaceConfig, p)
|
|
121
|
+
if (v === settingValues.get(p)) continue
|
|
122
|
+
settingValues.set(p, v)
|
|
123
|
+
settingPlugins.set(p, plugin.name)
|
|
124
|
+
}
|
|
97
125
|
// The run takes `plugins` from the file: an edited list validated and was dropped.
|
|
98
126
|
const now = workspaceConfig.plugins
|
|
99
127
|
if (now?.length !== list?.length || now?.some((p, i) => p !== list![i])) {
|
|
@@ -102,7 +130,7 @@ export async function loadWorkspacePlugins(
|
|
|
102
130
|
)
|
|
103
131
|
}
|
|
104
132
|
})
|
|
105
|
-
return { workspaceConfig, plugins, branch }
|
|
133
|
+
return { workspaceConfig, plugins, branch, settingPlugins }
|
|
106
134
|
}
|
|
107
135
|
|
|
108
136
|
const isRef = (r: unknown): boolean =>
|
|
@@ -128,6 +156,17 @@ export function lockOfDiscovery(
|
|
|
128
156
|
return discoveredLock.get(projects)
|
|
129
157
|
}
|
|
130
158
|
|
|
159
|
+
/** The nested workspace roots (absolute) the lock a discovery read records, by the array it returned. */
|
|
160
|
+
const discoveredWalls = new WeakMap<readonly ProjectMeta[], readonly string[]>()
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Where every project's outputs stop besides its nested projects: the
|
|
164
|
+
* nested workspaces `vx lock` found. None for an array no discovery made.
|
|
165
|
+
*/
|
|
166
|
+
export function nestedWorkspacesOf(projects: readonly ProjectMeta[]): readonly string[] {
|
|
167
|
+
return discoveredWalls.get(projects) ?? []
|
|
168
|
+
}
|
|
169
|
+
|
|
131
170
|
/**
|
|
132
171
|
* The package graph a selection pass built over a discovery, by that
|
|
133
172
|
* discovery's array, when it folded no task edge in: the graph a run that
|
|
@@ -143,6 +182,31 @@ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: Pack
|
|
|
143
182
|
discoveredGraph.set(projects, graph)
|
|
144
183
|
}
|
|
145
184
|
|
|
185
|
+
/** The plugin whose `discover` entry made each project the lock does not hold. */
|
|
186
|
+
const addedBy = new WeakMap<object, string>()
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Why a project a `discover` plugin added holds no config while its
|
|
190
|
+
* directory has one git ignores, or undefined: `vx lock` reads only the
|
|
191
|
+
* configs git lists, so "run `vx lock`" could never fix it. `config` is
|
|
192
|
+
* the file on disk, looked for when not given. One git spawn, asked only
|
|
193
|
+
* of a directory holding a config.
|
|
194
|
+
*/
|
|
195
|
+
export function ignoredConfigRefusal(
|
|
196
|
+
root: string,
|
|
197
|
+
meta: { readonly name: string; readonly dir: string },
|
|
198
|
+
config?: string,
|
|
199
|
+
): string | undefined {
|
|
200
|
+
const by = addedBy.get(meta)
|
|
201
|
+
if (by === undefined) return undefined
|
|
202
|
+
const file =
|
|
203
|
+
config ?? PROJECT_CONFIG_FILENAMES.map((n) => path.join(meta.dir, n)).find((f) => existsSync(f))
|
|
204
|
+
if (file === undefined) return undefined
|
|
205
|
+
const rel = relPosix(root, file)
|
|
206
|
+
if (checkIgnoreSync(root, [rel])?.has(rel) !== true) return undefined
|
|
207
|
+
return `plugin '${by}' adds project ${meta.name}, but git ignores ${rel}, so \`vx lock\` cannot read it — unignore it or delete it`
|
|
208
|
+
}
|
|
209
|
+
|
|
146
210
|
/**
|
|
147
211
|
* The workspace's projects: the directories the lock records (a run never
|
|
148
212
|
* walks for configs), then the plugin `discover` stage, which may add
|
|
@@ -158,7 +222,13 @@ export async function discoverProjects(
|
|
|
158
222
|
/** The run's enumeration, which `DiscoverContext.worktreeChanges` reads (and starts). */
|
|
159
223
|
git: LazyGitEnumeration = lazyGitEnumeration(workspaceRoot),
|
|
160
224
|
): Promise<ProjectMeta[]> {
|
|
161
|
-
const
|
|
225
|
+
const read = await lock()
|
|
226
|
+
const projects = lockedProjects(workspaceRoot, read.projects)
|
|
227
|
+
if (read.nestedWorkspaces !== undefined)
|
|
228
|
+
discoveredWalls.set(
|
|
229
|
+
projects,
|
|
230
|
+
read.nestedWorkspaces.map((rel) => path.join(workspaceRoot, rel)),
|
|
231
|
+
)
|
|
162
232
|
// Registered whatever the hooks: an `--affected` selection reads its
|
|
163
233
|
// untracked files from this walk, and the run reuses it (I-26).
|
|
164
234
|
discoveredGit.set(projects, git)
|
|
@@ -176,7 +246,10 @@ export async function discoverProjects(
|
|
|
176
246
|
{ workspaceRoot, cacheDir, warn, worktreeChanges },
|
|
177
247
|
async (entry, plugin) => {
|
|
178
248
|
const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
|
|
179
|
-
if (found.added)
|
|
249
|
+
if (found.added) {
|
|
250
|
+
byDir.set(found.meta.dir, found.meta)
|
|
251
|
+
addedBy.set(found.meta, plugin.name)
|
|
252
|
+
}
|
|
180
253
|
return found
|
|
181
254
|
},
|
|
182
255
|
)
|
|
@@ -259,6 +332,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
259
332
|
// behind `^build`. Left out, its edit replayed the dependant's stale
|
|
260
333
|
// output as a hit (X-9).
|
|
261
334
|
const projectStage = hasHook(plugins, 'project')
|
|
335
|
+
// A hook that set one project's `dir` to another's moved it there: the
|
|
336
|
+
// graph's directory guard read the moved entry, and its outputs clean
|
|
337
|
+
// emptied the other project's.
|
|
338
|
+
if (projectStage) freezeDeep(args.projectMetas)
|
|
262
339
|
const configured = args.projectMetas.filter(
|
|
263
340
|
(m) => projectStage || (typeof m.configPath === 'string' && m.configPath.length > 0),
|
|
264
341
|
)
|
|
@@ -372,9 +449,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
372
449
|
}
|
|
373
450
|
const frozen: ProjectConfig =
|
|
374
451
|
meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
|
|
375
|
-
// The lock shares a task
|
|
376
|
-
// works on
|
|
377
|
-
|
|
452
|
+
// The lock shares a task, or a part of one, every place declares alike,
|
|
453
|
+
// in this project too: an edit in place works on a copy that shares
|
|
454
|
+
// nothing. `structuredClone` keeps the sharing; the lock is JSON.
|
|
455
|
+
const config: ProjectConfig = projectStage ? JSON.parse(JSON.stringify(frozen)) : frozen
|
|
378
456
|
if (projectStage) {
|
|
379
457
|
// The `project` stage edits the validated object in place; core
|
|
380
458
|
// re-validates after EACH plugin so a plugin can only produce what
|
|
@@ -391,7 +469,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
391
469
|
warn: args.warn,
|
|
392
470
|
name: meta.name,
|
|
393
471
|
dir: meta.dir,
|
|
394
|
-
|
|
472
|
+
// The view `discover` was handed: nx() keys its graph on its identity.
|
|
473
|
+
projects: readOnly(args.projectMetas),
|
|
395
474
|
lock: args.lock === true,
|
|
396
475
|
},
|
|
397
476
|
(plugin) => {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// What a plugin is handed to observe is read-only. A setup handler that set
|
|
2
|
+
// a failed outcome's status to `success` made the run pass and its
|
|
3
|
+
// dependants run; a sink that cleared a record's `failedAttempts` cleared
|
|
4
|
+
// the outcome's own, and the run's failure log crashed on it. A write
|
|
5
|
+
// throws, inside the plugin's own call, where core already reports a throw
|
|
6
|
+
// naming the plugin.
|
|
7
|
+
|
|
8
|
+
import type { RunEvent } from '../report/index.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Freezes `value` and everything it holds, in place: for an object core
|
|
12
|
+
* built for plugins and no longer writes (a telemetry record, the run
|
|
13
|
+
* report, the project list). An object already frozen is taken as done, so
|
|
14
|
+
* each is walked once.
|
|
15
|
+
*/
|
|
16
|
+
export function freezeDeep<T>(value: T): T {
|
|
17
|
+
if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
|
|
18
|
+
// A typed array with elements cannot be frozen; none is in a record.
|
|
19
|
+
if (ArrayBuffer.isView(value)) return value
|
|
20
|
+
Object.freeze(value)
|
|
21
|
+
for (const key of Object.keys(value)) freezeDeep((value as Record<string, unknown>)[key])
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The write a read-only view refuses: a TypeError, as a frozen object's is, saying why. */
|
|
26
|
+
function refused(key: string | symbol): TypeError {
|
|
27
|
+
return new TypeError(
|
|
28
|
+
`'${String(key)}' is read-only: vx hands a plugin what it observes, not what the run decides`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The run's events as a plugin sees them: a read-only view of each, made at
|
|
34
|
+
* the first read of each object and kept for the run, so every plugin reads
|
|
35
|
+
* the same view and a node keeps one identity from its start to its end.
|
|
36
|
+
* A view, not a copy: core still writes an outcome after its task ends
|
|
37
|
+
* (`serverExit`), and a frozen deep copy of each node and outcome was ~5 us
|
|
38
|
+
* of every task a hook heard (10,000 tasks: 45 ms).
|
|
39
|
+
*/
|
|
40
|
+
export function readOnlyEvents(): (event: RunEvent) => RunEvent {
|
|
41
|
+
return readOnlyViews()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What a stage hands a plugin that core goes on using live: a task node in
|
|
46
|
+
* `key`, `schedule` and `admit` (core writes its key parts, platform and
|
|
47
|
+
* deps after, so it is not frozen), the project list in `discover` (core
|
|
48
|
+
* names and grows it after each plugin). A key hook that moved a node's
|
|
49
|
+
* `projectDir` ran it in another project; an admit hook that rewrote its
|
|
50
|
+
* command ran it under the declared one's key. One view per object for the
|
|
51
|
+
* process, so a plugin that keys on identity (nx() on the project list,
|
|
52
|
+
* from `discover` to `project`) sees one object across stages.
|
|
53
|
+
*/
|
|
54
|
+
export const readOnly: <T>(value: T) => T = readOnlyViews()
|
|
55
|
+
|
|
56
|
+
/** `nodes` as a `schedule` hook reads it: each node a read-only view, the map itself unwritable. */
|
|
57
|
+
export function readOnlyNodes<V>(nodes: ReadonlyMap<string, V>): ReadonlyMap<string, V> {
|
|
58
|
+
const out = new Map<string, V>()
|
|
59
|
+
for (const [id, node] of nodes) out.set(id, readOnly(node))
|
|
60
|
+
for (const key of ['set', 'delete', 'clear'] as const)
|
|
61
|
+
Object.defineProperty(out, key, {
|
|
62
|
+
value: () => {
|
|
63
|
+
throw refused(key)
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read-only views of plain objects and arrays, each made at its first read
|
|
71
|
+
* and kept. Only plain objects and arrays are wrapped: a Map's methods need
|
|
72
|
+
* the Map itself, and a frozen object is read-only already.
|
|
73
|
+
*/
|
|
74
|
+
function readOnlyViews(): <T>(value: T) => T {
|
|
75
|
+
const views = new WeakMap<object, object>()
|
|
76
|
+
const handler: ProxyHandler<object> = {
|
|
77
|
+
// A frozen target's own values are its own: a proxy must answer them
|
|
78
|
+
// unchanged, and they are read-only already.
|
|
79
|
+
get: (target, key) => {
|
|
80
|
+
const value: unknown = Reflect.get(target, key)
|
|
81
|
+
return Object.isFrozen(target) ? value : view(value)
|
|
82
|
+
},
|
|
83
|
+
// Each refused whatever the plugin's mode: a frozen object's write is
|
|
84
|
+
// silent outside strict code.
|
|
85
|
+
set: (_target, key) => {
|
|
86
|
+
throw refused(key)
|
|
87
|
+
},
|
|
88
|
+
defineProperty: (_target, key) => {
|
|
89
|
+
throw refused(key)
|
|
90
|
+
},
|
|
91
|
+
deleteProperty: (_target, key) => {
|
|
92
|
+
throw refused(key)
|
|
93
|
+
},
|
|
94
|
+
setPrototypeOf: () => {
|
|
95
|
+
throw refused('prototype')
|
|
96
|
+
},
|
|
97
|
+
preventExtensions: () => {
|
|
98
|
+
throw refused('extensions')
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
function view<T>(value: T): T {
|
|
102
|
+
if (typeof value !== 'object' || value === null) return value
|
|
103
|
+
// Before the frozen check: the project list is viewed in `discover`
|
|
104
|
+
// and frozen before `project`, and keeps its one view.
|
|
105
|
+
const seen = views.get(value)
|
|
106
|
+
if (seen !== undefined) return seen as T
|
|
107
|
+
if (Object.isFrozen(value)) return value
|
|
108
|
+
const proto: unknown = Object.getPrototypeOf(value)
|
|
109
|
+
if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return value
|
|
110
|
+
const made = new Proxy(value, handler)
|
|
111
|
+
views.set(value, made)
|
|
112
|
+
return made as T
|
|
113
|
+
}
|
|
114
|
+
return view
|
|
115
|
+
}
|
|
@@ -455,13 +455,17 @@ function workTreePath(root: string): string | undefined {
|
|
|
455
455
|
* Normalize a git remote URL so every checkout of the same repository
|
|
456
456
|
* derives the SAME workspace id: `git@github.com:o/r.git`,
|
|
457
457
|
* `ssh://git@github.com/o/r`, and `https://github.com/o/r.git` all
|
|
458
|
-
* reduce to `github.com/o/r`.
|
|
458
|
+
* reduce to `github.com/o/r`. No credential survives: the userinfo ends
|
|
459
|
+
* at the last `@` before the path, as a URL parser splits it (a password
|
|
460
|
+
* may hold `@`), and a query or fragment (`?private_token=`) is dropped.
|
|
459
461
|
*/
|
|
460
462
|
export function normalizeRemoteUrl(raw: string): string {
|
|
461
463
|
let s = raw.trim().toLowerCase()
|
|
462
464
|
const hadProtocol = /^[a-z+]+:\/\//.test(s)
|
|
463
465
|
s = s.replace(/^[a-z+]+:\/\//, '') // protocol
|
|
464
|
-
s = s.replace(
|
|
466
|
+
s = s.replace(/[?#].*$/s, '') // ?query, #fragment
|
|
467
|
+
// user[:pass]@; scp shorthand has no password, and its path follows a `:`.
|
|
468
|
+
s = s.replace(hadProtocol ? /^[^/]*@/ : /^[^@/]+@/, '')
|
|
465
469
|
// A `:port` only appears in a protocol-form URL (`ssh://host:2222/path`); the
|
|
466
470
|
// scp shorthand `host:path` has no port. STRIP it (the old code reinserted it
|
|
467
471
|
// as a path segment, so `ssh://host:2222/o/r` and `https://host/o/r` — the
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// cache.db's `failures` (cache/run-notes.ts), the newest 50 failed runs kept.
|
|
4
4
|
|
|
5
5
|
import type { Database } from 'bun:sqlite'
|
|
6
|
-
import { indexDb, type Cache, keepRunNotes, keptFailures } from '../cache/index.js'
|
|
6
|
+
import { indexDb, type Cache, keepRunNotes, keptFailures, storedLog } from '../cache/index.js'
|
|
7
7
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
8
8
|
import { decodeOutputLog } from './output-log.js'
|
|
9
9
|
import { PASS_STATUSES } from '../history/index.js'
|
|
10
|
-
import { maskedLine, splitTaskId } from '../util/index.js'
|
|
10
|
+
import { maskedLine, splitTaskId, stripEscapes } from '../util/index.js'
|
|
11
11
|
import { fileLocations, fileMemo, type OutputLocation } from '../ui/index.js'
|
|
12
12
|
|
|
13
13
|
/** A failed task of a run and what it said. */
|
|
@@ -74,7 +74,7 @@ export function writeRunFailures(
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const plain = (log: string): string =>
|
|
77
|
-
|
|
77
|
+
stripEscapes(
|
|
78
78
|
decodeOutputLog(log)
|
|
79
79
|
.map((c) => c.text)
|
|
80
80
|
.join(''),
|
|
@@ -166,8 +166,9 @@ export interface TaskLog {
|
|
|
166
166
|
status: string
|
|
167
167
|
/**
|
|
168
168
|
* Where the output came from: the run's kept failure output, the cache
|
|
169
|
-
* entry the task saved or hit
|
|
170
|
-
* vx kept none (an uncached task that
|
|
169
|
+
* entry the task saved or hit while it still holds that output (masked as
|
|
170
|
+
* it was replayed), or null when vx kept none (an uncached task that
|
|
171
|
+
* passed, a skip, an entry another run wrote since).
|
|
171
172
|
*/
|
|
172
173
|
source: 'failure' | 'cache' | null
|
|
173
174
|
/** Plain text; '' when `source` is null. The files it names: `runFailures`. */
|
|
@@ -183,7 +184,7 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
|
|
|
183
184
|
if (project === '' || task === '') return null
|
|
184
185
|
const row = db
|
|
185
186
|
.query(
|
|
186
|
-
`SELECT run_id AS runId, hash, status FROM runs
|
|
187
|
+
`SELECT run_id AS runId, hash, status, cache_hit AS hit, started_at AS startedAt FROM runs
|
|
187
188
|
WHERE project = ? AND task = ? AND run_id IS NOT NULL ${runId === undefined ? '' : 'AND run_id = ?'}
|
|
188
189
|
ORDER BY id DESC LIMIT 1`,
|
|
189
190
|
)
|
|
@@ -191,6 +192,8 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
|
|
|
191
192
|
runId: string
|
|
192
193
|
hash: string
|
|
193
194
|
status: string
|
|
195
|
+
hit: number | null
|
|
196
|
+
startedAt: number
|
|
194
197
|
} | null
|
|
195
198
|
if (row === null) return null
|
|
196
199
|
const base = { runId: row.runId, taskId, status: row.status }
|
|
@@ -213,17 +216,32 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
|
|
|
213
216
|
before === undefined || tries === null || tries === undefined
|
|
214
217
|
? ''
|
|
215
218
|
: `${before}${attemptHeading(tries, tries, 'passed')}`
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
+
// The entry is the key's, not the run's: a `--force` run kept the one it
|
|
220
|
+
// found, and a key evicted and saved again, or saved by two checkouts
|
|
221
|
+
// of one store at once, holds another run's output. The entry names
|
|
222
|
+
// the run that wrote it and when, so the log is this run's only when
|
|
223
|
+
// this run wrote it, or this run hit it and nothing wrote it since;
|
|
224
|
+
// otherwise the log reads as not kept. Keeping every run's log would
|
|
225
|
+
// hold a copy per run of what the entry already holds once.
|
|
226
|
+
const entry = db
|
|
227
|
+
.query('SELECT run_id AS writer, created_at AS writtenAt FROM entries WHERE hash = ?')
|
|
228
|
+
.get(row.hash) as { writer: string | null; writtenAt: number } | null
|
|
229
|
+
const own =
|
|
230
|
+
entry !== null &&
|
|
231
|
+
(entry.writer === row.runId || (row.hit === 1 && entry.writtenAt < row.startedAt))
|
|
232
|
+
const stored = own
|
|
233
|
+
? (db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
|
|
234
|
+
stdout: Uint8Array
|
|
235
|
+
} | null)
|
|
236
|
+
: null
|
|
219
237
|
if (stored !== null) {
|
|
220
238
|
// An entry saved before masking, or by another machine, can hold a
|
|
221
239
|
// secret-named variable's value: masked as a hit's replay masks it.
|
|
222
240
|
// This process's env only: `vx last` loads no config, so a task's
|
|
223
241
|
// `env.define` / `env.secret` values are not known here.
|
|
224
242
|
const output = maskedLine(
|
|
225
|
-
|
|
226
|
-
decodeOutputLog(stored.stdout)
|
|
243
|
+
stripEscapes(
|
|
244
|
+
decodeOutputLog(storedLog(stored.stdout))
|
|
227
245
|
.map((c) => c.text)
|
|
228
246
|
.join(''),
|
|
229
247
|
),
|
|
@@ -231,12 +249,23 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
|
|
|
231
249
|
return { ...base, source: 'cache', output: prefix + output }
|
|
232
250
|
}
|
|
233
251
|
// An entry that printed nothing stores no stdout row: kept, and empty.
|
|
234
|
-
if (
|
|
252
|
+
if (own) {
|
|
235
253
|
return { ...base, source: 'cache', output: prefix }
|
|
236
254
|
}
|
|
237
255
|
}
|
|
238
|
-
// A pass with no entry that took a retry still kept its failed attempts
|
|
256
|
+
// A pass with no entry that took a retry still kept its failed attempts,
|
|
257
|
+
// then says the last passed: without it the log read as a failure.
|
|
239
258
|
const kept = row.status === 'failed' ? undefined : keptFailures(db, row.runId).get(taskId)
|
|
240
|
-
if (kept !== undefined)
|
|
259
|
+
if (kept !== undefined) {
|
|
260
|
+
const tries = (
|
|
261
|
+
db
|
|
262
|
+
.query(
|
|
263
|
+
'SELECT attempts FROM runs WHERE run_id = ? AND project = ? AND task = ? ORDER BY id DESC LIMIT 1',
|
|
264
|
+
)
|
|
265
|
+
.get(row.runId, project, task) as { attempts: number | null } | null
|
|
266
|
+
)?.attempts
|
|
267
|
+
const passed = typeof tries === 'number' ? attemptHeading(tries, tries, 'passed') : ''
|
|
268
|
+
return { ...base, source: 'failure', output: kept.output + passed }
|
|
269
|
+
}
|
|
241
270
|
return { ...base, source: null, output: '' }
|
|
242
271
|
}
|
|
@@ -8,15 +8,26 @@ import { UserError } from '../util/index.js'
|
|
|
8
8
|
* The recorded run `raw` names: itself when recorded, else the one run
|
|
9
9
|
* whose id starts with it. Null when none does; a prefix several runs share
|
|
10
10
|
* is refused with those runs, since picking one would replay the wrong run.
|
|
11
|
+
* A run a stop ended keeps task rows but no header (item 854), and `vx why`
|
|
12
|
+
* and `vx last --log` print its id, so its rows name it too.
|
|
11
13
|
*/
|
|
12
14
|
export function resolveRunId(db: Database, raw: string, verb: string): string | null {
|
|
13
15
|
// Run ids are lower-case UUIDs; one typed or pasted in upper case is the same id.
|
|
14
16
|
const id = raw.toLowerCase()
|
|
15
|
-
const exact = db
|
|
17
|
+
const exact = db
|
|
18
|
+
.query(
|
|
19
|
+
'SELECT 1 FROM invocations WHERE run_id = ?1 UNION ALL SELECT 1 FROM run_ids WHERE run_id = ?1 LIMIT 1',
|
|
20
|
+
)
|
|
21
|
+
.get(id)
|
|
16
22
|
if (exact !== null) return id
|
|
23
|
+
// A range on `run_ids` (every run with rows), so its index answers:
|
|
24
|
+
// `substr` read every row.
|
|
25
|
+
// `~` sorts after every character of an id.
|
|
17
26
|
const rows = db
|
|
18
27
|
.query(
|
|
19
|
-
|
|
28
|
+
`SELECT run_id FROM invocations WHERE substr(run_id, 1, ?1) = ?2
|
|
29
|
+
UNION SELECT run_id FROM run_ids WHERE run_id >= ?2 AND run_id < ?2 || '~'
|
|
30
|
+
ORDER BY run_id DESC LIMIT 6`,
|
|
20
31
|
)
|
|
21
32
|
.all(id.length, id) as Array<{ run_id: string }>
|
|
22
33
|
if (rows.length === 0) return null
|