@vzn/vx 0.0.651 → 0.0.652
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 -3
- package/package.json +5 -5
- package/schemas/show.json +14 -20
- package/src/bin.ts +12 -0
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +19 -5
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +74 -14
- package/src/cache/layer.ts +6 -6
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/run-history.ts +46 -17
- package/src/cache/schema.ts +4 -1
- package/src/cli/cache.ts +2 -2
- package/src/cli/help.ts +2 -2
- package/src/cli/index.ts +24 -0
- package/src/cli/info.ts +1 -1
- package/src/cli/last.ts +34 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/show.ts +7 -10
- package/src/cli/watch-judge.ts +1 -1
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +11 -8
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +40 -1
- package/src/exec/index.ts +1 -0
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +314 -29
- package/src/exec/task-proxy.ts +93 -0
- package/src/graph/definition.ts +16 -3
- package/src/graph/scheduler.ts +3 -1
- package/src/graph/task-graph.ts +24 -23
- package/src/history/failure-mode.ts +24 -1
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +5 -5
- package/src/index.ts +4 -2
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +51 -18
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/miss-save.ts +27 -8
- package/src/orchestrator/persistent-task.ts +2 -0
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +2 -2
- package/src/orchestrator/plugin-host.ts +6 -2
- package/src/orchestrator/plugin.ts +6 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-failures.ts +61 -12
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +86 -67
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +92 -19
- package/src/orchestrator/sandbox-request.ts +50 -7
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +4 -4
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +3 -3
- package/src/report/framed-output.ts +16 -7
- package/src/report/logger.ts +1 -1
- package/src/report/plain-output.ts +2 -0
- package/src/report/summary.ts +5 -5
- package/src/settings.ts +8 -8
- package/src/util/config-frame.ts +128 -6
- package/src/util/paths.ts +1 -1
- package/src/util/settle.ts +2 -1
- package/src/util/tail.ts +4 -1
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +66 -61
- package/src/workspace/filter.ts +8 -1
- package/src/workspace/migration.ts +12 -6
|
@@ -3,49 +3,40 @@
|
|
|
3
3
|
// VX_UPDATE_CONTRACT=1 that test. `vx init --plugin <seam>` writes these.
|
|
4
4
|
|
|
5
5
|
export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: string }>> = {
|
|
6
|
-
admit: {
|
|
7
|
-
plugin:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
cache:
|
|
12
|
-
plugin:
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
},
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
test: "// A test for the example `
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
plugin:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
plugin:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
36
|
-
project: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
},
|
|
41
|
-
|
|
42
|
-
plugin:
|
|
43
|
-
"// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
|
|
44
|
-
test: "// A test for the example `schedule` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-schedule-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
|
|
45
|
-
},
|
|
46
|
-
telemetry: {
|
|
47
|
-
plugin:
|
|
48
|
-
"// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry(ctx) {\n // From the root, not the cwd: a run in a project's directory appends\n // to the same file as one at the root.\n const to = path.resolve(ctx.workspaceRoot, file)\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(to, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
|
|
49
|
-
test: "// A test for the example `telemetry` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger, type TelemetrySink } from '@vzn/vx'\nimport { jsonlTelemetry } from './telemetry.ts'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n\n// Read against the process's cwd, a relative file was another file for a\n// run started in a project's directory, written inside that project.\nit('writes a relative file at the workspace root', async () => {\n const elsewhere = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-cwd-'))\n const cwd = process.cwd()\n process.chdir(elsewhere)\n try {\n const plugin = jsonlTelemetry('runs.jsonl')\n const sink = (await plugin.telemetry!({ workspaceRoot: root } as never)) as TelemetrySink\n sink.onRunSummary!({ taskCount: 2, failedCount: 0, hitCount: 1 } as never)\n await sink.flush!(new AbortController().signal)\n } finally {\n process.chdir(cwd)\n await rm(elsewhere, { recursive: true, force: true })\n }\n expect(await Bun.file(path.join(root, 'runs.jsonl')).text()).toBe(\n '{\"tasks\":2,\"failed\":0,\"cached\":1}\\n',\n )\n})\n",
|
|
50
|
-
},
|
|
6
|
+
"admit": {
|
|
7
|
+
"plugin": "// `admit`: whether a ready task starts now, beside what runs here. At most\n// one task of the named `task` runs at a time (say, one that holds a port).\n// Synchronous and cheap: it is asked at every dispatch.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function oneAtATime(task: string): VxPlugin {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n return definePlugin(import.meta, {\n admit(node, ctx) {\n return !named(node.id) || !ctx.running.some((r) => named(r.id))\n },\n })\n}\n",
|
|
8
|
+
"test": "// A test for the example `admit` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-admit-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `oneAtATime()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'admit.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { oneAtATime } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n"
|
|
9
|
+
},
|
|
10
|
+
"cache": {
|
|
11
|
+
"plugin": "// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type ArtifactInfo, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<ArtifactInfo | null> {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { size: file.size } : null\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes. The aside\n // name is the writer's own: a pid is not (two puts here, containers on\n // one volume).\n const tmp = path.join(this.endpoint, `.${hash}.${crypto.randomUUID()}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n // From the root, not the cwd: a run in a project's directory reads\n // the same store as one at the root.\n return new DirRemote(path.resolve(ctx.workspaceRoot, dir))\n },\n })\n}\n",
|
|
12
|
+
"test": "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\nimport { dirCache } from './cache.ts'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] }, cache: true,\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n\n// Read against the process's cwd, a relative directory was another store\n// for a run started in a project's directory than for one at the root.\nit('reads a relative directory from the workspace root', async () => {\n const layer = await dirCache('remote').cache!({ workspaceRoot: root } as never)\n expect(layer?.endpoint).toBe(path.join(root, 'remote'))\n})\n\n// Two writers of one key shared one temporary name (`.<hash>.<pid>`, and\n// containers on one volume share pids): one unlinked the other's file\n// mid-put, or linked it while the other rewrote it.\nit('two puts of one key at once both land, as one entry', async () => {\n const store = path.join(root, 'remote')\n const layer = (await dirCache(store).cache!({ workspaceRoot: root } as never))!\n const bytes = new Blob(['x'.repeat(1 << 20)])\n const puts = await Promise.allSettled([\n layer.put('k', bytes, { durationMs: 1 }),\n layer.put('k', bytes, { durationMs: 1 }),\n ])\n expect(puts.map((p) => p.status)).toEqual(['fulfilled', 'fulfilled'])\n expect(await readdir(store)).toEqual(['k'])\n expect((await (await layer.get('k'))!.body.arrayBuffer()).byteLength).toBe(1 << 20)\n})\n"
|
|
13
|
+
},
|
|
14
|
+
"commands": {
|
|
15
|
+
"plugin": "// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, ui, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n ui.say('error', `vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n ui.data('out', `${ctx.cacheDir}\\n`)\n return 0\n },\n },\n },\n })\n}\n",
|
|
16
|
+
"test": "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n artifacts: {\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n },\n artifactsFor: () => ({\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n }),\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(process.stdout, 'write').mockImplementation(() => true)\n const err = spyOn(process.stderr, 'write').mockImplementation(() => true)\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache\\n']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n // On CI the line is coloured: the words are what this asserts.\n expect(err.mock.calls.map(([l]) => Bun.stripANSI(String(l)))).toEqual([\n 'vx cache-dir: takes no arguments, got x\\n',\n ])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n"
|
|
17
|
+
},
|
|
18
|
+
"executor": {
|
|
19
|
+
"plugin": "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // A stop before the spawn is work not to start: an abort fires no\n // listener added after it, so the command would run unstopped.\n if (req.signal?.aborted === true) {\n const exitCode = req.signal.reason === 'SIGINT' ? 130 : 143\n return { exitCode, durationMs: 0, stdout: '', stderr: '', violations: [], where: 'shell' }\n }\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const kill = (signal: NodeJS.Signals): void => {\n try {\n process.kill(-child.pid, signal)\n } catch {\n // already gone\n }\n }\n // Core abandons an executor 2 s after the abort: a group that ignores\n // SIGTERM is killed before then, or it would run on unreached.\n let killTimer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => {\n kill('SIGTERM')\n killTimer = setTimeout(() => kill('SIGKILL'), 1_000)\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const readers: { cancel(): Promise<void> }[] = []\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const reader = stream.getReader()\n readers.push(reader)\n const decoder = new TextDecoder()\n let all = ''\n for (let r = await reader.read(); !r.done; r = await reader.read()) {\n const text = decoder.decode(r.value, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const drained = Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n ])\n const exitCode = await child.exited\n // A pipe ends only when every writer closes it: a process the command\n // left running (`server &`) would hold the task open. What it writes\n // after a short grace is not the task's.\n const cut = setTimeout(() => readers.forEach((r) => void r.cancel()), 250)\n const [stdout, stderr] = await drained\n clearTimeout(cut)\n req.signal?.removeEventListener('abort', stop)\n clearTimeout(killTimer)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
|
|
20
|
+
"test": "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\nimport { shellExecutor } from './executor.ts'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: '300ms' } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n\n// An abort fires no listener added after it: a request stopped before\n// `execute` ran its command anyway, in a group nothing would stop.\nit('starts nothing for a request already aborted', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n stop.abort('SIGTERM')\n const r = await executor.execute({\n command: 'sleep 0.3; echo ran > ran.txt',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n await Bun.sleep(600)\n expect([r.exitCode, await Bun.file(path.join(root, 'ran.txt')).exists()]).toEqual([143, false])\n})\n\n// Core abandons an executor 2 s after its abort: a group that ignored\n// SIGTERM ran on, unreached, after the run had ended.\nit('kills a group that ignores SIGTERM after a grace', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n const started = Date.now()\n setTimeout(() => stop.abort('SIGTERM'), 200)\n const r = await executor.execute({\n command: \"trap '' TERM; sleep 30\",\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n expect([r.exitCode, Date.now() - started < 10_000]).toEqual([137, true])\n}, 40_000)\n\n// A pipe ends only when every writer closes it: a process the command\n// left running (`server &`) held the task open until it exited.\nit('ends when the shell exits, though a process it left holds the pipes', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const started = Date.now()\n const r = await executor.execute({\n command: 'sleep 5 & echo up',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n } as never)\n expect([r.exitCode, r.stdout, Date.now() - started < 3_000]).toEqual([0, 'up\\n', true])\n}, 20_000)\n"
|
|
21
|
+
},
|
|
22
|
+
"graph": {
|
|
23
|
+
"plugin": "// `graph`: the run's edges. Every task named `task` waits for the one\n// before it, so they run one after another across projects: in dependency\n// order, then id order, so an added edge never closes a cycle. Core checks\n// the edited graph again (a missing dep, a cycle).\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function chain(task: string): VxPlugin {\n return definePlugin(import.meta, {\n graph(nodes) {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n const ids: string[] = []\n const seen = new Set<string>()\n const visit = (id: string): void => {\n if (seen.has(id)) return\n seen.add(id)\n for (const dep of nodes.get(id)?.deps ?? []) visit(dep)\n if (named(id)) ids.push(id)\n }\n for (const id of [...nodes.keys()].filter(named).sort()) visit(id)\n for (const [i, id] of ids.entries()) {\n const prev = ids[i - 1]\n const node = nodes.get(id)!\n if (prev !== undefined && !node.deps.includes(prev)) node.deps.push(prev)\n }\n },\n })\n}\n",
|
|
24
|
+
"test": "// A test for the example `graph` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `chain()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'graph.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { chain } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n\n// Id order alone put a#build before b#build even when a#build already\n// waits on b#build: the added edge closed a cycle and the run was refused.\nit('chains in dependency order, never closing a cycle', async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'true' }, dependsOn: ['b#build'] } } }`,\n )\n for (const name of ['b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- b#build',\n 'b#build <- ',\n 'c#build <- a#build',\n ])\n}, 20_000)\n"
|
|
25
|
+
},
|
|
26
|
+
"key": {
|
|
27
|
+
"plugin": "// `key`: extra cache-key material. The value of each named environment\n// variable goes into every cached task's key and is named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
|
|
28
|
+
"test": "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n inputs: { files: ['package.json'] }, outputs: { files: [] }, cache: true,\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n"
|
|
29
|
+
},
|
|
30
|
+
"project": {
|
|
31
|
+
"plugin": "// `project`: a project's tasks. Every script in the project's package.json\n// becomes a task of the same name that runs it, unless the project already\n// declares one. Core reads no package.json, so the plugin does. Core\n// re-validates each edit as it would the user's own config, so a blank\n// script, or one no task can be named after (`taskNameProblem`: a `#`\n// comment key), is left out.\nimport { definePlugin, taskNameProblem, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n async project(config, ctx) {\n const pkg: unknown = await Bun.file(`${ctx.dir}/package.json`)\n .json()\n .catch(() => null)\n const scripts = (pkg as { scripts?: unknown } | null)?.scripts\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || command.trim() === '') continue\n if (config.tasks[name] !== undefined || taskNameProblem(name) !== null) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
|
|
32
|
+
"test": "// A test for the example `project` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-project-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `scriptTasks()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'project.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { scriptTasks } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n\n// A blank script, or one no task can be named after (a `#` comment key),\n// was handed to core, which refused the project: every run of it failed.\nit('leaves out a blank script and one no task can be named after', async () => {\n const dir = await pkg('a', `export default { tasks: {} }`, {\n '# notes': 'see README',\n test: '',\n hello: 'echo hi > hello.txt',\n })\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`)).toEqual(['a#hello success'])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n}, 20_000)\n"
|
|
33
|
+
},
|
|
34
|
+
"schedule": {
|
|
35
|
+
"plugin": "// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
|
|
36
|
+
"test": "// A test for the example `schedule` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-schedule-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n"
|
|
37
|
+
},
|
|
38
|
+
"telemetry": {
|
|
39
|
+
"plugin": "// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry(ctx) {\n // From the root, not the cwd: a run in a project's directory appends\n // to the same file as one at the root.\n const to = path.resolve(ctx.workspaceRoot, file)\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(to, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
|
|
40
|
+
"test": "// A test for the example `telemetry` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger, type TelemetrySink } from '@vzn/vx'\nimport { jsonlTelemetry } from './telemetry.ts'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n\n// Read against the process's cwd, a relative file was another file for a\n// run started in a project's directory, written inside that project.\nit('writes a relative file at the workspace root', async () => {\n const elsewhere = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-cwd-'))\n const cwd = process.cwd()\n process.chdir(elsewhere)\n try {\n const plugin = jsonlTelemetry('runs.jsonl')\n const sink = (await plugin.telemetry!({ workspaceRoot: root } as never)) as TelemetrySink\n sink.onRunSummary!({ taskCount: 2, failedCount: 0, hitCount: 1 } as never)\n await sink.flush!(new AbortController().signal)\n } finally {\n process.chdir(cwd)\n await rm(elsewhere, { recursive: true, force: true })\n }\n expect(await Bun.file(path.join(root, 'runs.jsonl')).text()).toBe(\n '{\"tasks\":2,\"failed\":0,\"cached\":1}\\n',\n )\n})\n"
|
|
41
|
+
}
|
|
51
42
|
}
|
package/src/cli/show.ts
CHANGED
|
@@ -507,17 +507,14 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
|
507
507
|
add('inputs.tasks', list(inputs.tasks))
|
|
508
508
|
add('inputs.runtime', list(inputs.runtime))
|
|
509
509
|
add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
|
|
510
|
+
add('inputs.stability.strategy', inputs.stability?.strategy)
|
|
511
|
+
add('inputs.stability.severity', inputs.stability?.severity)
|
|
510
512
|
}
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
add('outputs.files', list(
|
|
514
|
-
add('outputs.workspaceFiles', list(
|
|
515
|
-
add(
|
|
516
|
-
'outputs.clean',
|
|
517
|
-
cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
|
|
518
|
-
)
|
|
519
|
-
add('inputStability.strategy', cache.inputStability?.strategy)
|
|
520
|
-
add('inputStability.severity', cache.inputStability?.severity)
|
|
513
|
+
const outputs = task.outputs
|
|
514
|
+
if (outputs !== undefined) {
|
|
515
|
+
add('outputs.files', list(outputs.files))
|
|
516
|
+
add('outputs.workspaceFiles', list(outputs.workspaceFiles))
|
|
517
|
+
add('outputs.clean', outputs.clean === undefined ? undefined : outputs.clean ? 'yes' : 'no')
|
|
521
518
|
}
|
|
522
519
|
return rows
|
|
523
520
|
}
|
package/src/cli/watch-judge.ts
CHANGED
|
@@ -319,7 +319,7 @@ export class ChangeJudge {
|
|
|
319
319
|
'warn',
|
|
320
320
|
byServer
|
|
321
321
|
? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
|
|
322
|
-
: `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in
|
|
322
|
+
: `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
|
|
323
323
|
'out',
|
|
324
324
|
)
|
|
325
325
|
}
|
package/src/cli/watch-set.ts
CHANGED
|
@@ -132,8 +132,8 @@ export async function sweepConfigs(
|
|
|
132
132
|
}
|
|
133
133
|
const foldTasks = (dir: string, config: ProjectConfig, names: Set<string> | null): void => {
|
|
134
134
|
for (const [name, task] of Object.entries(config.tasks ?? {})) {
|
|
135
|
-
add(dir, task.
|
|
136
|
-
add(workspaceRoot, task.
|
|
135
|
+
add(dir, task.outputs?.files)
|
|
136
|
+
add(workspaceRoot, task.outputs?.workspaceFiles)
|
|
137
137
|
if (names !== null && !names.has(name)) continue
|
|
138
138
|
if (
|
|
139
139
|
task.inputs === undefined &&
|
|
@@ -147,8 +147,8 @@ export async function sweepConfigs(
|
|
|
147
147
|
for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
|
|
148
148
|
}
|
|
149
149
|
for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
|
|
150
|
-
addInputs(dir, task.inputs?.files, task.
|
|
151
|
-
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.
|
|
150
|
+
addInputs(dir, task.inputs?.files, task.outputs?.files)
|
|
151
|
+
addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.outputs?.workspaceFiles)
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
|
package/src/cli/watch.ts
CHANGED
|
@@ -539,7 +539,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
539
539
|
const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
|
|
540
540
|
try {
|
|
541
541
|
let label: string | undefined = first
|
|
542
|
-
|
|
542
|
+
/** The lock this cycle's run read; none for the initial run. */
|
|
543
|
+
let lockAtRun: string | undefined
|
|
543
544
|
// The initial run is collected even after a stop: its server is the
|
|
544
545
|
// loop's to stop.
|
|
545
546
|
while (initial !== undefined || (label !== undefined && !stop.aborted)) {
|
|
@@ -612,13 +613,6 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
612
613
|
// A config's own throw or a plugin's failure may quote a secret (L-11).
|
|
613
614
|
const message = maskedLine(err instanceof Error ? err.message : String(err))
|
|
614
615
|
sayNotice('error', 'vx watch', `cycle failed: ${message}`)
|
|
615
|
-
} finally {
|
|
616
|
-
// A re-lock since this cycle began: the re-arm below can
|
|
617
|
-
// close the watcher its event was still on its way to (FSEvents'
|
|
618
|
-
// latency, a poller's tick), and the new lock ran nothing until
|
|
619
|
-
// the next edit (macOS CI, 2026-10-23). A cycle that read the
|
|
620
|
-
// lock mid-write failed on it, and is the same case.
|
|
621
|
-
if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
622
616
|
}
|
|
623
617
|
// After a failed cycle too: a package added with a config that does
|
|
624
618
|
// not load yet fails its cycle, and unarmed, the fix to that config
|
|
@@ -627,6 +621,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
627
621
|
reread = false
|
|
628
622
|
await rearm()
|
|
629
623
|
}
|
|
624
|
+
// A re-lock since this cycle began: the re-arm can close the watcher
|
|
625
|
+
// its event was still on its way to (FSEvents' latency, a poller's
|
|
626
|
+
// tick), and the new lock ran nothing until the next edit (macOS CI,
|
|
627
|
+
// 2026-10-23). A cycle that read the lock mid-write failed on it, and
|
|
628
|
+
// is the same case. Asked after the re-arm, not when the run ends: a
|
|
629
|
+
// lock written in between was the re-arm's to read, its new pollers
|
|
630
|
+
// took it as their baseline, and it was never an event (macOS CI,
|
|
631
|
+
// 2026-11-06).
|
|
632
|
+
if (lockAtRun !== undefined && lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
|
|
630
633
|
// What landed mid-run is judged on settled state, one window
|
|
631
634
|
// after the run, under the label of what actually arrived.
|
|
632
635
|
current = undefined
|