@vzn/vx 0.0.16 → 0.0.21
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 +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -3,6 +3,13 @@
|
|
|
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
14
|
export type { RunOptions, RunSummary } from './options.js'
|
|
8
15
|
// Re-surface the cache policy contract (defined in the cache module) so
|
|
@@ -11,13 +18,16 @@ export type { RunOptions, RunSummary } from './options.js'
|
|
|
11
18
|
export { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
|
|
12
19
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
13
20
|
export type { Logger, OutputView } from './logger.js'
|
|
14
|
-
export type { RunPlan, PlannedTask, CacheStatus } from './plan.js'
|
|
21
|
+
export type { RunPlan, PlannedTask, PlanPrediction, CacheStatus } from './plan.js'
|
|
22
|
+
export { formatDuration } from './summary.js'
|
|
15
23
|
export {
|
|
16
24
|
createEventBus,
|
|
17
25
|
wireForwarder,
|
|
18
26
|
toWireEvent,
|
|
19
27
|
projectNode,
|
|
20
28
|
projectOutcome,
|
|
29
|
+
outcomeWord,
|
|
30
|
+
outcomeLabel,
|
|
21
31
|
} from './events.js'
|
|
22
32
|
export type {
|
|
23
33
|
EventBus,
|
|
@@ -27,39 +37,7 @@ export type {
|
|
|
27
37
|
TaskView,
|
|
28
38
|
OutcomeView,
|
|
29
39
|
} from './events.js'
|
|
30
|
-
export {
|
|
31
|
-
export { createWireRenderer } from './wire-render.js'
|
|
32
|
-
export { optionsToRequest, requestToOptions } from './protocol.js'
|
|
33
|
-
export type { ClientMessage, RunBackend, RunRequest, RunResult, ServerMessage } from './protocol.js'
|
|
34
|
-
export { formatRunReportMarkdown } from './run-report.js'
|
|
35
|
-
export {
|
|
36
|
-
clientMessageToEnvelope,
|
|
37
|
-
decodeEnvelope,
|
|
38
|
-
encodeForNDJSON,
|
|
39
|
-
encodeForSSE,
|
|
40
|
-
encodeForWS,
|
|
41
|
-
ENVELOPE_ERRORS,
|
|
42
|
-
envelopeToClientMessage,
|
|
43
|
-
envelopeToServerMessage,
|
|
44
|
-
isEnvelope,
|
|
45
|
-
isNotification,
|
|
46
|
-
isRequest,
|
|
47
|
-
makeError,
|
|
48
|
-
makeNotification,
|
|
49
|
-
makeRequest,
|
|
50
|
-
makeResponse,
|
|
51
|
-
serverMessageToEnvelope,
|
|
52
|
-
WIRE_CHANNELS,
|
|
53
|
-
WIRE_PROTOCOL_VERSION,
|
|
54
|
-
} from './wire.js'
|
|
55
|
-
export type {
|
|
56
|
-
Envelope,
|
|
57
|
-
ErrorResponse,
|
|
58
|
-
Notification,
|
|
59
|
-
Request as WireRequest,
|
|
60
|
-
Response as WireResponse,
|
|
61
|
-
WireChannel,
|
|
62
|
-
} from './wire.js'
|
|
40
|
+
export { escapeMarkdownCell, formatRunReportMarkdown, type RunResult } from './run-report.js'
|
|
63
41
|
export {
|
|
64
42
|
EmptyHistoryProvider,
|
|
65
43
|
type HistoryProvider,
|
|
@@ -67,24 +45,74 @@ export {
|
|
|
67
45
|
LocalHistoryProvider,
|
|
68
46
|
type TaskHistory,
|
|
69
47
|
} from './history.js'
|
|
70
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
detectFlaky,
|
|
50
|
+
type FailureMode,
|
|
51
|
+
type FlakyCandidate,
|
|
52
|
+
type FlakyFinding,
|
|
53
|
+
type FlakyTask,
|
|
54
|
+
flakyTasks,
|
|
55
|
+
} from './failure-mode.js'
|
|
71
56
|
export {
|
|
72
57
|
installPlugins,
|
|
73
|
-
type BackendContext,
|
|
74
58
|
type CacheContext,
|
|
75
|
-
type
|
|
76
|
-
type
|
|
59
|
+
type ExecutorContext,
|
|
60
|
+
type CommandContext,
|
|
61
|
+
type GraphHookContext,
|
|
62
|
+
type KeyHookContext,
|
|
63
|
+
type PluginCommand,
|
|
64
|
+
type ProjectHookContext,
|
|
65
|
+
type ScheduleHookContext,
|
|
66
|
+
type AdmitContext,
|
|
67
|
+
type WorkspaceHookContext,
|
|
77
68
|
type InstallPluginsArgs,
|
|
78
69
|
type Plugin,
|
|
79
70
|
type PluginContext,
|
|
80
71
|
type PluginHookHandlers,
|
|
81
72
|
type PluginHookName,
|
|
82
73
|
type PluginSetupContext,
|
|
74
|
+
type FingerprintChange,
|
|
75
|
+
type FingerprintClaim,
|
|
76
|
+
type FingerprintContext,
|
|
83
77
|
type VxPlugin,
|
|
78
|
+
definePlugin,
|
|
79
|
+
type PluginHooks,
|
|
80
|
+
type PluginOrigin,
|
|
84
81
|
} from './plugin.js'
|
|
85
|
-
export {
|
|
82
|
+
export {
|
|
83
|
+
applyConfigHooks,
|
|
84
|
+
applyGraphHooks,
|
|
85
|
+
applyKeyHooks,
|
|
86
|
+
applyProjectHooks,
|
|
87
|
+
applyScheduleHooks,
|
|
88
|
+
claimedAffected,
|
|
89
|
+
fingerprintClaims,
|
|
90
|
+
hasHook,
|
|
91
|
+
CACHE_LAYER_METHODS,
|
|
92
|
+
resolveCache,
|
|
93
|
+
resolveExecutors,
|
|
94
|
+
} from './plugin-host.js'
|
|
95
|
+
export {
|
|
96
|
+
lockfileClaim,
|
|
97
|
+
reachDigests,
|
|
98
|
+
type LockfileClaimHooks,
|
|
99
|
+
type LockfileClaimOptions,
|
|
100
|
+
type ReachGraph,
|
|
101
|
+
} from './lockfile-claim.js'
|
|
86
102
|
export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
87
|
-
|
|
103
|
+
// The bounded log-capture buffer every telemetry sink shares — see the
|
|
104
|
+
// module header for why one implementation, not one per sink.
|
|
105
|
+
export { LOG_WIRE_VERSION, TaskLogBuffer } from './task-log-buffer.js'
|
|
106
|
+
export type { TaskLogBundle, TaskLogEntry } from './task-log-buffer.js'
|
|
107
|
+
export {
|
|
108
|
+
assembleRunSummary,
|
|
109
|
+
createTelemetrySource,
|
|
110
|
+
deriveCacheSource,
|
|
111
|
+
isCacheHit,
|
|
112
|
+
isPassStatus,
|
|
113
|
+
TASK_STATUSES,
|
|
114
|
+
TELEMETRY_SCHEMA_VERSION,
|
|
115
|
+
} from './telemetry.js'
|
|
88
116
|
export type {
|
|
89
117
|
CacheSource,
|
|
90
118
|
RunContextRecord,
|
|
@@ -95,80 +123,36 @@ export type {
|
|
|
95
123
|
TelemetrySink,
|
|
96
124
|
TelemetrySource,
|
|
97
125
|
} from './telemetry.js'
|
|
98
|
-
// `diffOutputTrees` is the ONE tree-diff implementation — the verify verdict
|
|
99
|
-
// and a serve's cross-machine fingerprint diff must never drift on it.
|
|
100
|
-
export { diffOutputTrees } from './verify.js'
|
|
101
126
|
export { deriveStableKeys } from './stable-keys.js'
|
|
102
127
|
export type { DeriveStableKeysArgs, StableKey } from './stable-keys.js'
|
|
103
|
-
export {
|
|
104
|
-
|
|
128
|
+
export {
|
|
129
|
+
captureDefaultBranch,
|
|
130
|
+
captureGitContext,
|
|
131
|
+
captureHostContext,
|
|
132
|
+
captureWorkspaceIdentity,
|
|
133
|
+
detectCi,
|
|
134
|
+
} from './run-context.js'
|
|
135
|
+
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
|
|
105
136
|
export {
|
|
106
137
|
cacheKeyDiff,
|
|
107
|
-
compareRuns,
|
|
108
138
|
explainCacheKey as explainCacheKeyQuery,
|
|
109
|
-
getBottlenecks,
|
|
110
|
-
getCacheBreakdown,
|
|
111
|
-
getCacheSavings,
|
|
112
|
-
getCacheStatsSql,
|
|
113
|
-
getFlakiestTasks,
|
|
114
|
-
getHistory,
|
|
115
|
-
getHitRateSplit,
|
|
116
139
|
getInvocation,
|
|
117
|
-
getParallelismHistory,
|
|
118
|
-
getPeriodComparison,
|
|
119
|
-
getPrunableEntries,
|
|
120
|
-
getRecentFailures,
|
|
121
|
-
getRegressions,
|
|
122
140
|
getRun,
|
|
123
|
-
getRunHeatmap,
|
|
124
|
-
getRunTrends,
|
|
125
|
-
getStorageGrowth,
|
|
126
|
-
getTaskDetail,
|
|
127
|
-
getTopTimeBurners,
|
|
128
|
-
listCacheEntries,
|
|
129
141
|
listInvocations,
|
|
130
|
-
listProjects,
|
|
131
142
|
listRuns,
|
|
132
143
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
133
144
|
} from './metrics.js'
|
|
134
145
|
export type {
|
|
135
|
-
BottleneckRow,
|
|
136
146
|
CacheEntryRow,
|
|
137
147
|
CacheKeyDiff,
|
|
138
148
|
CacheKeyExplanation,
|
|
139
|
-
CacheProjectRow,
|
|
140
|
-
CacheSavings,
|
|
141
|
-
CacheStatsResult,
|
|
142
|
-
CompareRuns,
|
|
143
|
-
CompareTaskRow,
|
|
144
|
-
CompareTaskSide,
|
|
145
|
-
FailureRow,
|
|
146
|
-
FlakyTask,
|
|
147
|
-
GetHistoryArgs,
|
|
148
|
-
HeatmapCell,
|
|
149
|
-
HitRateSplit,
|
|
150
149
|
InputDiffEntry,
|
|
151
150
|
InvocationDetail,
|
|
152
151
|
InvocationRow,
|
|
153
|
-
ListCacheEntriesArgs,
|
|
154
152
|
ListInvocationsArgs,
|
|
155
153
|
ListRunsArgs,
|
|
156
|
-
ParallelismPoint,
|
|
157
|
-
PeriodComparison,
|
|
158
|
-
PeriodComparisonArgs,
|
|
159
|
-
PeriodStats,
|
|
160
|
-
PrunableEntry,
|
|
161
|
-
ProjectRollup,
|
|
162
|
-
RegressedTask,
|
|
163
|
-
RegressionArgs,
|
|
164
|
-
TaskMover,
|
|
165
154
|
RunDetail,
|
|
166
155
|
RunSummaryRow,
|
|
167
|
-
StoragePoint,
|
|
168
|
-
TaskDetail,
|
|
169
|
-
TaskHistoryRow,
|
|
170
|
-
TopTaskRow,
|
|
171
|
-
TrendBucket,
|
|
172
|
-
TrendPoint,
|
|
173
156
|
WhyDidThisRerun,
|
|
174
157
|
} from './metrics.js'
|
|
158
|
+
export { collectInfo, type CollectInfoOptions, type InfoFacts } from './doctor.js'
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
// conservative exclusion). Those tasks still get an exec-tier probe
|
|
39
39
|
// reuse entry so there's no double work.
|
|
40
40
|
|
|
41
|
+
import { span } from '../util/index.js'
|
|
41
42
|
import type { CacheEntry, CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
42
43
|
import type { TaskNode } from '../graph/index.js'
|
|
43
44
|
import { deriveStableKeys } from './stable-keys.js'
|
|
@@ -95,10 +96,13 @@ const EMPTY: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
|
95
96
|
*/
|
|
96
97
|
export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<ShortCircuit> {
|
|
97
98
|
let stableKeys
|
|
99
|
+
const endKeys = span('stable keys')
|
|
98
100
|
try {
|
|
99
101
|
stableKeys = await deriveStableKeys(args)
|
|
100
102
|
} catch {
|
|
101
103
|
return EMPTY
|
|
104
|
+
} finally {
|
|
105
|
+
endKeys()
|
|
102
106
|
}
|
|
103
107
|
const candidates = stableKeys.filter(({ node }) => node.config.cache !== undefined)
|
|
104
108
|
if (candidates.length === 0) return EMPTY
|
|
@@ -125,6 +129,24 @@ export async function startLocalShortCircuit(args: ShortCircuitArgs): Promise<Sh
|
|
|
125
129
|
// it); a miss stays exec-tier with a known-miss reuse entry. The pool
|
|
126
130
|
// keeps the up-front pass from being a serial pre-phase on a wide warm
|
|
127
131
|
// graph — the probes parallelize at the run's concurrency.
|
|
132
|
+
// A layer with a batched `get` answers every probe in a couple of
|
|
133
|
+
// queries; the pool below is for layers without one.
|
|
134
|
+
if (args.cache.getMany !== undefined) {
|
|
135
|
+
try {
|
|
136
|
+
const endProbe = span('probe')
|
|
137
|
+
const hits = await args.cache.getMany(candidates.map((c) => c.hash))
|
|
138
|
+
endProbe()
|
|
139
|
+
for (const { hash, node } of candidates) {
|
|
140
|
+
const hit = hits.get(hash) ?? null
|
|
141
|
+
preProbed.set(node.id, { hash, hit })
|
|
142
|
+
if (hit !== null && !anyWorkspaceOutputs) restoreTier.add(node.id)
|
|
143
|
+
}
|
|
144
|
+
return { preProbed, restoreTier }
|
|
145
|
+
} catch {
|
|
146
|
+
// Fall through to the per-hash pool, which isolates a failing probe
|
|
147
|
+
// to its own task.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
128
150
|
let next = 0
|
|
129
151
|
const workers = Math.max(1, Math.min(args.concurrency, candidates.length))
|
|
130
152
|
const pump = async (): Promise<void> => {
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// The claimant's shell: what every lockfile plugin needs around its
|
|
2
|
+
// parser. A plugin that keys each project on its own dependency closure
|
|
3
|
+
// from a lockfile (`@vzn/vx-lockfile`) claims the file
|
|
4
|
+
// (`VxPlugin.fingerprint`), folds one digest per project through `key`,
|
|
5
|
+
// and answers `--affected` by digesting both sides of a change. Only the
|
|
6
|
+
// parser differs per package manager; the memo, the per-run gate, the
|
|
7
|
+
// fallback for a project the file does not list and the `--affected`
|
|
8
|
+
// diff are one implementation here, so a third lockfile is a parser.
|
|
9
|
+
//
|
|
10
|
+
// The digests are memoised on disk under the cache dir by the file's
|
|
11
|
+
// xxh3, so a warm run pays one read + hash + a small JSON read, never a
|
|
12
|
+
// parse; within one process (a run, a watch cycle) the file's size and
|
|
13
|
+
// mtime gate even the read; and per run the read happens once (a WeakMap
|
|
14
|
+
// on the context core hands every `key` call of a run).
|
|
15
|
+
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { rename, stat } from 'node:fs/promises'
|
|
18
|
+
import { xxh3hex } from '../util/index.js'
|
|
19
|
+
import type { TaskNode } from '../graph/index.js'
|
|
20
|
+
import type {
|
|
21
|
+
FingerprintChange,
|
|
22
|
+
FingerprintClaim,
|
|
23
|
+
FingerprintContext,
|
|
24
|
+
KeyHookContext,
|
|
25
|
+
} from './plugin.js'
|
|
26
|
+
|
|
27
|
+
export interface LockfileClaimOptions {
|
|
28
|
+
/** The root-relative lockfile name, one of `WORKSPACE_FINGERPRINT_FILES`. */
|
|
29
|
+
readonly file: string
|
|
30
|
+
/**
|
|
31
|
+
* Every project's digest from the file's text: importer directory
|
|
32
|
+
* (root-relative POSIX, `.` for the root) → digest. Called once per
|
|
33
|
+
* distinct content; must be deterministic and throw on a file it
|
|
34
|
+
* cannot read (the plugin's name heads the error).
|
|
35
|
+
*/
|
|
36
|
+
readonly digest: (text: string) => ReadonlyMap<string, string>
|
|
37
|
+
/** Folded into the memo's identity: bump when `digest` folds differently. */
|
|
38
|
+
readonly version: number
|
|
39
|
+
/**
|
|
40
|
+
* `'project'` (default): each task folds its own project's digest.
|
|
41
|
+
* `'workspace'`: the whole file's hash into every task — the coarse key
|
|
42
|
+
* core would fold, through the plugin.
|
|
43
|
+
*/
|
|
44
|
+
readonly scope?: 'project' | 'workspace'
|
|
45
|
+
/**
|
|
46
|
+
* The key part's name, as `vx why` shows it under the plugin's name
|
|
47
|
+
* (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
|
|
48
|
+
*/
|
|
49
|
+
readonly part?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The two hooks a lockfile plugin spreads into `definePlugin`. */
|
|
53
|
+
export interface LockfileClaimHooks {
|
|
54
|
+
readonly fingerprint: FingerprintClaim
|
|
55
|
+
key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Digests {
|
|
59
|
+
/** xxh3 of the lockfile bytes; '' when the file is absent. */
|
|
60
|
+
readonly lock: string
|
|
61
|
+
readonly importers: ReadonlyMap<string, string>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Memo {
|
|
65
|
+
version: number
|
|
66
|
+
lock: string
|
|
67
|
+
importers: Record<string, string>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
|
|
71
|
+
const { file, digest, version } = options
|
|
72
|
+
const scope = options.scope ?? 'project'
|
|
73
|
+
const part = options.part ?? 'deps'
|
|
74
|
+
if (scope !== 'project' && scope !== 'workspace') {
|
|
75
|
+
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
76
|
+
}
|
|
77
|
+
// Per workspace root: what the last read saw. `size` + `mtimeMs` gate a
|
|
78
|
+
// re-read; the content hash decides whether the digests are current.
|
|
79
|
+
const seen = new Map<string, { size: number; mtimeMs: number; digests: Digests }>()
|
|
80
|
+
// Per run: every `key` call of one run gets the same context object.
|
|
81
|
+
const perRun = new WeakMap<object, Promise<Digests>>()
|
|
82
|
+
|
|
83
|
+
const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
|
|
84
|
+
const full = path.join(workspaceRoot, file)
|
|
85
|
+
let st: { size: number; mtimeMs: number }
|
|
86
|
+
try {
|
|
87
|
+
st = await stat(full)
|
|
88
|
+
} catch {
|
|
89
|
+
const digests: Digests = { lock: '', importers: new Map() }
|
|
90
|
+
seen.set(workspaceRoot, { size: -1, mtimeMs: -1, digests })
|
|
91
|
+
return digests
|
|
92
|
+
}
|
|
93
|
+
const last = seen.get(workspaceRoot)
|
|
94
|
+
if (last !== undefined && last.size === st.size && last.mtimeMs === st.mtimeMs) {
|
|
95
|
+
return last.digests
|
|
96
|
+
}
|
|
97
|
+
const bytes = await Bun.file(full).bytes()
|
|
98
|
+
const lock = xxh3hex(bytes)
|
|
99
|
+
if (last !== undefined && last.digests.lock === lock) {
|
|
100
|
+
seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests: last.digests })
|
|
101
|
+
return last.digests
|
|
102
|
+
}
|
|
103
|
+
const memoFile = path.join(cacheDir, 'lockfile-claims', `${file}.json`)
|
|
104
|
+
const importers =
|
|
105
|
+
scope === 'workspace'
|
|
106
|
+
? new Map<string, string>()
|
|
107
|
+
: ((await readMemo(memoFile, version, lock)) ??
|
|
108
|
+
(await computeAndMemo(memoFile, version, lock, digest(decode(bytes)))))
|
|
109
|
+
const digests: Digests = { lock, importers }
|
|
110
|
+
seen.set(workspaceRoot, { size: st.size, mtimeMs: st.mtimeMs, digests })
|
|
111
|
+
return digests
|
|
112
|
+
}
|
|
113
|
+
const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
|
|
114
|
+
let p = perRun.get(ctx)
|
|
115
|
+
if (p === undefined) {
|
|
116
|
+
p = load(ctx.workspaceRoot, ctx.cacheDir)
|
|
117
|
+
perRun.set(ctx, p)
|
|
118
|
+
}
|
|
119
|
+
return p
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
fingerprint: {
|
|
124
|
+
files: [file],
|
|
125
|
+
affected(change: FingerprintChange, ctx: FingerprintContext) {
|
|
126
|
+
// One side absent: the file appeared or went — every project's
|
|
127
|
+
// installed tree is in question, and core's answer (all) is right.
|
|
128
|
+
if (change.before === null || change.after === null) return undefined
|
|
129
|
+
if (scope === 'workspace') return undefined
|
|
130
|
+
const before = digest(decode(change.before))
|
|
131
|
+
const after = digest(decode(change.after))
|
|
132
|
+
const names: string[] = []
|
|
133
|
+
for (const p of ctx.projects) {
|
|
134
|
+
const importer = importerOf(ctx.workspaceRoot, p.dir)
|
|
135
|
+
if (digestFor(before, importer) !== digestFor(after, importer)) names.push(p.name)
|
|
136
|
+
}
|
|
137
|
+
return names
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
async key(task, ctx) {
|
|
141
|
+
const digests = await loadOnce(ctx)
|
|
142
|
+
if (digests.lock === '') return undefined
|
|
143
|
+
if (scope === 'workspace') return { [part]: digests.lock }
|
|
144
|
+
return {
|
|
145
|
+
[part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* A project's digest: its own importer's, or — for a project the lockfile
|
|
153
|
+
* has no importer for (outside the workspace's `packages`) — the root
|
|
154
|
+
* importer's, the only installed tree it can resolve from. A file with
|
|
155
|
+
* neither folds a constant.
|
|
156
|
+
*/
|
|
157
|
+
function digestFor(importers: ReadonlyMap<string, string>, importer: string): string {
|
|
158
|
+
return importers.get(importer) ?? importers.get('.') ?? 'no-importer'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
|
|
162
|
+
function importerOf(workspaceRoot: string, projectDir: string): string {
|
|
163
|
+
const rel = path.relative(workspaceRoot, projectDir)
|
|
164
|
+
return rel === '' ? '.' : rel.split(path.sep).join('/')
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function readMemo(
|
|
168
|
+
memoFile: string,
|
|
169
|
+
version: number,
|
|
170
|
+
lock: string,
|
|
171
|
+
): Promise<Map<string, string> | undefined> {
|
|
172
|
+
try {
|
|
173
|
+
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
174
|
+
if (memo.version !== version || memo.lock !== lock) return undefined
|
|
175
|
+
return new Map(Object.entries(memo.importers))
|
|
176
|
+
} catch {
|
|
177
|
+
return undefined
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function computeAndMemo(
|
|
182
|
+
memoFile: string,
|
|
183
|
+
version: number,
|
|
184
|
+
lock: string,
|
|
185
|
+
importers: ReadonlyMap<string, string>,
|
|
186
|
+
): Promise<ReadonlyMap<string, string>> {
|
|
187
|
+
const memo: Memo = { version, lock, importers: Object.fromEntries(importers) }
|
|
188
|
+
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
189
|
+
// concurrent runs each land a whole one.
|
|
190
|
+
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
191
|
+
try {
|
|
192
|
+
await Bun.write(tmp, JSON.stringify(memo))
|
|
193
|
+
await rename(tmp, memoFile)
|
|
194
|
+
} catch {
|
|
195
|
+
// The memo is a speed-up, never a requirement: the digests are
|
|
196
|
+
// computed either way, and the next run computes them again.
|
|
197
|
+
}
|
|
198
|
+
return importers
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function decode(bytes: Uint8Array): string {
|
|
202
|
+
return new TextDecoder().decode(bytes)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// --- Reach digests -----------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
/** A dependency graph: one material string per node and its out-edges by index. */
|
|
208
|
+
export interface ReachGraph {
|
|
209
|
+
readonly material: readonly string[]
|
|
210
|
+
readonly edges: ReadonlyArray<readonly number[]>
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* One digest per node over everything the node reaches, Merkle-style: a
|
|
215
|
+
* change anywhere in a node's reach moves its digest, a change elsewhere
|
|
216
|
+
* does not. Lockfiles carry dependency cycles, so the unit is the
|
|
217
|
+
* strongly connected component: Tarjan's walk (iterative — a dependency
|
|
218
|
+
* chain can be thousands deep) emits components children-first, and each
|
|
219
|
+
* folds its members' material (sorted) and its child components' digests
|
|
220
|
+
* (sorted). O(nodes + edges): 1000 importers over 3000 packages digest in
|
|
221
|
+
* ~20 ms where one traversal per importer took 400.
|
|
222
|
+
*/
|
|
223
|
+
export function reachDigests(g: ReachGraph): string[] {
|
|
224
|
+
const n = g.material.length
|
|
225
|
+
const idx = new Int32Array(n).fill(-1)
|
|
226
|
+
const low = new Int32Array(n)
|
|
227
|
+
const onStack = new Uint8Array(n)
|
|
228
|
+
const stack: number[] = []
|
|
229
|
+
const comp = new Int32Array(n).fill(-1)
|
|
230
|
+
const compHash: string[] = []
|
|
231
|
+
let counter = 0
|
|
232
|
+
for (let root = 0; root < n; root++) {
|
|
233
|
+
if (idx[root] !== -1) continue
|
|
234
|
+
const frames: Array<[number, number]> = [[root, 0]]
|
|
235
|
+
idx[root] = low[root] = counter++
|
|
236
|
+
stack.push(root)
|
|
237
|
+
onStack[root] = 1
|
|
238
|
+
while (frames.length > 0) {
|
|
239
|
+
const frame = frames[frames.length - 1]!
|
|
240
|
+
const v = frame[0]
|
|
241
|
+
const out = g.edges[v]!
|
|
242
|
+
if (frame[1] < out.length) {
|
|
243
|
+
const w = out[frame[1]++]!
|
|
244
|
+
if (idx[w] === -1) {
|
|
245
|
+
idx[w] = low[w] = counter++
|
|
246
|
+
stack.push(w)
|
|
247
|
+
onStack[w] = 1
|
|
248
|
+
frames.push([w, 0])
|
|
249
|
+
} else if (onStack[w] === 1) {
|
|
250
|
+
low[v] = Math.min(low[v]!, idx[w]!)
|
|
251
|
+
}
|
|
252
|
+
continue
|
|
253
|
+
}
|
|
254
|
+
frames.pop()
|
|
255
|
+
if (frames.length > 0) {
|
|
256
|
+
const u = frames[frames.length - 1]![0]
|
|
257
|
+
low[u] = Math.min(low[u]!, low[v]!)
|
|
258
|
+
}
|
|
259
|
+
if (low[v] !== idx[v]) continue
|
|
260
|
+
const members: number[] = []
|
|
261
|
+
for (;;) {
|
|
262
|
+
const w = stack.pop()!
|
|
263
|
+
onStack[w] = 0
|
|
264
|
+
comp[w] = compHash.length
|
|
265
|
+
members.push(w)
|
|
266
|
+
if (w === v) break
|
|
267
|
+
}
|
|
268
|
+
const id = compHash.length
|
|
269
|
+
const children = new Set<string>()
|
|
270
|
+
for (const m of members) {
|
|
271
|
+
for (const w of g.edges[m]!) if (comp[w] !== id) children.add(compHash[comp[w]!]!)
|
|
272
|
+
}
|
|
273
|
+
let h = Bun.hash.xxHash3(`members:${members.length}`)
|
|
274
|
+
for (const m of members.map((m) => g.material[m]!).sort()) h = Bun.hash.xxHash3(`${m}\n`, h)
|
|
275
|
+
h = Bun.hash.xxHash3(`children:${children.size}`, h)
|
|
276
|
+
for (const c of [...children].sort()) h = Bun.hash.xxHash3(`${c}\n`, h)
|
|
277
|
+
compHash.push(h.toString(16).padStart(16, '0'))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return Array.from({ length: n }, (_, i) => compHash[comp[i]!]!)
|
|
281
|
+
}
|