diff --git a/packages/tooling/vis/__tests__/commands/run/retry-budget.test.ts b/packages/tooling/vis/__tests__/commands/run/retry-budget.test.ts index 71a7afcd..10355977 100644 --- a/packages/tooling/vis/__tests__/commands/run/retry-budget.test.ts +++ b/packages/tooling/vis/__tests__/commands/run/retry-budget.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { createRetryBudget } from "../../../src/commands/run/handler"; +import { createRetryBudget } from "../../../src/commands/run/handler-parts/run-executor"; describe(createRetryBudget, () => { it("grants up to the requested amount and decrements remaining", () => { diff --git a/packages/tooling/vis/__tests__/commands/run/retry-spawn.test.ts b/packages/tooling/vis/__tests__/commands/run/retry-spawn.test.ts index 1ab735f2..1ee38fe5 100644 --- a/packages/tooling/vis/__tests__/commands/run/retry-spawn.test.ts +++ b/packages/tooling/vis/__tests__/commands/run/retry-spawn.test.ts @@ -140,7 +140,9 @@ describe(resolveRetrySpawn, () => { it("builds the retry spawn with resolveRetrySpawn in the handler's retry branch", () => { expect.assertions(4); - const handlerSource = readFileSync(fileURLToPath(new URL("../../../src/commands/run/handler.ts", import.meta.url)), "utf8"); + const handlerSource = ["handler.ts", "handler-parts/run-modes.ts"] + .map((file) => readFileSync(fileURLToPath(new URL(`../../../src/commands/run/${file}`, import.meta.url)), "utf8")) + .join("\n"); const start = handlerSource.indexOf("loopAction === \"retry\" && retryTaskId"); const end = handlerSource.indexOf("retryTaskId = null;", start); @@ -158,7 +160,9 @@ describe(resolveRetrySpawn, () => { it("retries the resolved task command, not the raw override", () => { expect.assertions(3); - const handlerSource = readFileSync(fileURLToPath(new URL("../../../src/commands/run/handler.ts", import.meta.url)), "utf8"); + const handlerSource = ["handler.ts", "handler-parts/run-modes.ts"] + .map((file) => readFileSync(fileURLToPath(new URL(`../../../src/commands/run/${file}`, import.meta.url)), "utf8")) + .join("\n"); const start = handlerSource.indexOf("loopAction === \"retry\" && retryTaskId"); const retryBranch = handlerSource.slice(start, handlerSource.indexOf("retryTaskId = null;", start)); diff --git a/packages/tooling/vis/__tests__/commands/run/run-teardown-on-throw.test.ts b/packages/tooling/vis/__tests__/commands/run/run-teardown-on-throw.test.ts new file mode 100644 index 00000000..5f6ab621 --- /dev/null +++ b/packages/tooling/vis/__tests__/commands/run/run-teardown-on-throw.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import runExecute from "../../../src/commands/run/handler"; + +const state = vi.hoisted(() => { + return { + cleanupEphemeralServices: vi.fn(), + ctx: undefined as Record | undefined, + prepareRunRuntime: vi.fn(), + }; +}); + +// The phases before the runtime are stubbed; `injectMissingServices` plays the +// part of booting an ephemeral service by recording its pid file and run dir. +vi.mock(import("../../../src/commands/run/handler-parts/run-select"), () => { + return { + buildInitialTasks: () => false, + loadRunWorkspace: async () => state.ctx, + selectProjectsWithTarget: () => false, + selectRunProjects: async () => false, + }; +}); + +vi.mock(import("../../../src/commands/run/handler-parts/run-graph"), () => { + return { + buildRunGraph: () => {}, + printDryRunPlan: () => false, + runPreflightChecks: async () => {}, + shapeTaskGraph: () => {}, + }; +}); + +vi.mock(import("../../../src/commands/run/handler-parts/run-services"), () => { + return { + attachRegisteredServices: async () => { return { satisfiedServices: [], serviceEnvByTaskId: new Map() }; }, + finalizeServiceGraph: () => {}, + injectMissingServices: () => { + state.ctx!["ephemeralPidFiles"] = ["/ws/.vis/run/db.pid"]; + state.ctx!["serviceRunDir"] = "/ws/.vis/run"; + }, + }; +}); + +vi.mock(import("../../../src/commands/run/handler-parts/run-runtime"), async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, prepareRunRuntime: state.prepareRunRuntime }; +}); + +vi.mock(import("../../../src/commands/run/handler-parts/run-persistent-tasks"), async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, cleanupEphemeralServices: state.cleanupEphemeralServices }; +}); + +describe("vis run teardown", () => { + beforeEach(() => { + state.ctx = { + ephemeralPidFiles: [], + options: { stopServices: false }, + registryStartedIds: [], + registryStartedPids: new Map(), + serviceEventBridge: null, + serviceRunDir: undefined, + workspaceRoot: "/ws", + }; + state.cleanupEphemeralServices.mockReset(); + state.prepareRunRuntime.mockReset(); + }); + + it("tears down services it booted when a run:before hook throws", async () => { + expect.assertions(2); + + // A throwing `run:before` hook surfaces from inside prepareRunRuntime. + state.prepareRunRuntime.mockRejectedValue(new Error("run:before boom")); + + const logger = { debug: () => {}, error: () => {}, info: () => {}, warn: () => {} }; + + await expect( + runExecute({ + argument: ["test"], + logger, + options: {}, + visConfig: undefined, + workspaceRoot: "/ws", + } as never), + ).rejects.toThrow("run:before boom"); + + expect(state.cleanupEphemeralServices).toHaveBeenCalledWith( + expect.objectContaining({ pidFiles: ["/ws/.vis/run/db.pid"], runDir: "/ws/.vis/run" }), + ); + }); +}); diff --git a/packages/tooling/vis/__tests__/release/core/orchestrator-preflight-guard.test.ts b/packages/tooling/vis/__tests__/release/core/orchestrator-preflight-guard.test.ts new file mode 100644 index 00000000..96b36f09 --- /dev/null +++ b/packages/tooling/vis/__tests__/release/core/orchestrator-preflight-guard.test.ts @@ -0,0 +1,141 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildContext, publishContext } from "../../../src/release/core/orchestrator"; +import type { PublishResult } from "../../../src/release/core/package-managers/interface"; +import type { PublishContext } from "../../../src/release/core/version-actions/interface"; +import { VersionActions } from "../../../src/release/core/version-actions/interface"; + +const remote = vi.hoisted(() => { + return { + detectRemoteProvider: vi.fn(), + detectRepoSlug: vi.fn(), + }; +}); + +vi.mock(import("../../../src/release/core/remote/detect"), async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + createRemoteClient: () => { + return { detectRepoSlug: remote.detectRepoSlug }; + }, + detectRemoteProvider: remote.detectRemoteProvider, + }; +}); + +// Only the network-facing push check is stubbed; the remote is still a real +// git remote so `git remote get-url --push origin` runs for real. +vi.mock(import("../../../src/release/core/git"), async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + getCurrentBranch: async () => "main", + resolvePushCredentials: async () => undefined, + verifyPushAccess: async () => undefined, + }; +}); + +class StubVersionActions extends VersionActions { + public readonly id = "stub"; + + public readonly calls: string[] = []; + + public async readPublishedVersion(): Promise { + return undefined; + } + + public async publish(context: PublishContext): Promise { + this.calls.push(context.pkg.name); + + return { alreadyPublished: false, output: "ok", published: true }; + } +} + +const writeJson = (path: string, value: unknown): void => { + writeFileSync(path, `${JSON.stringify(value, null, 4)}\n`); +}; + +const setupFixture = (): string => { + const cwd = mkdtempSync(join(tmpdir(), "vis-orch-preflight-")); + + writeJson(join(cwd, "package.json"), { name: "fixture-root", packageManager: "pnpm@10.0.0", private: true, version: "0.0.0", workspaces: ["packages/*"] }); + writeFileSync(join(cwd, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + + mkdirSync(join(cwd, "packages", "a"), { recursive: true }); + writeJson(join(cwd, "packages", "a", "package.json"), { name: "@scope/a", version: "1.0.0" }); + + mkdirSync(join(cwd, ".vis", "release"), { recursive: true }); + writeFileSync(join(cwd, ".vis", "release", "feat.md"), `---\n"@scope/a": minor\n---\nbody\n`); + writeFileSync( + join(cwd, "vis.config.cjs"), + `module.exports = ${JSON.stringify({ release: { acknowledgeUnstable: true, defaultManaged: true } }, null, 4)};\n`, + ); + + execFileSync("git", ["init", "-q"], { cwd }); + execFileSync("git", ["remote", "add", "origin", "https://example.invalid/acme/repo.git"], { cwd }); + + return cwd; +}; + +describe("orchestrator: publish preflight reports failures instead of rejecting", () => { + let cwd: string; + + beforeEach(() => { + cwd = setupFixture(); + remote.detectRemoteProvider.mockReset(); + remote.detectRepoSlug.mockReset(); + }); + + afterEach(async () => { + await rm(cwd, { force: true, recursive: true }); + }); + + it("records a _preflight failure when the release-PR check cannot reach the remote", async () => { + expect.hasAssertions(); + + const actions = new StubVersionActions(); + const ctx = await buildContext({ + config: { acknowledgeUnstable: true, defaultManaged: true }, + cwd, + firstRelease: true, + }); + + // Only the version-pr channel runs the open-release-PR check. + Object.assign(ctx, { channel: { mode: "version-pr" } }); + remote.detectRemoteProvider.mockRejectedValue(new Error("remote unreachable")); + + const result = await publishContext(ctx, { noPush: true, noTag: true, publishActionsOverride: actions }); + + expect(actions.calls).toStrictEqual([]); + expect(result.published).toStrictEqual([]); + expect(result.failed).toStrictEqual([expect.objectContaining({ name: "_preflight", reason: expect.stringContaining("remote unreachable") })]); + }); + + it("records a _preflight failure when the push-rule check cannot resolve the repo", async () => { + expect.hasAssertions(); + + const actions = new StubVersionActions(); + const ctx = await buildContext({ + config: { acknowledgeUnstable: true, defaultManaged: true }, + cwd, + firstRelease: true, + }); + + remote.detectRemoteProvider.mockResolvedValue("github"); + remote.detectRepoSlug.mockRejectedValue(new Error("slug lookup failed")); + + const result = await publishContext(ctx, { noTag: true, publishActionsOverride: actions }); + + expect(actions.calls).toStrictEqual([]); + expect(result.published).toStrictEqual([]); + expect(result.failed).toStrictEqual([expect.objectContaining({ name: "_preflight", reason: expect.stringContaining("slug lookup failed") })]); + }); +}); diff --git a/packages/tooling/vis/schemas/vis-config.schema.json b/packages/tooling/vis/schemas/vis-config.schema.json index 84abaf01..82ecc460 100644 --- a/packages/tooling/vis/schemas/vis-config.schema.json +++ b/packages/tooling/vis/schemas/vis-config.schema.json @@ -6422,7 +6422,7 @@ } }, "additionalProperties": false, - "description": "A predicate used by {@link VisConfig.scopedTasks } . All listed constraints must match for the block to apply." + "description": "A predicate used by the `scopedTasks` config block. All listed constraints must match for the block to apply." }, "TaskArgument": { "type": "object", diff --git a/packages/tooling/vis/src/commands/cache/handler.ts b/packages/tooling/vis/src/commands/cache/handler.ts index 8a84d682..f9d73e8f 100644 --- a/packages/tooling/vis/src/commands/cache/handler.ts +++ b/packages/tooling/vis/src/commands/cache/handler.ts @@ -1,243 +1,23 @@ -import { readdirSync } from "node:fs"; -import { mkdtemp, readdir, realpath, rm, stat } from "node:fs/promises"; +import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { createInterface } from "node:readline"; import type { Toolbox } from "@visulima/cerebro"; import { isAccessibleSync } from "@visulima/fs"; import { formatBytes } from "@visulima/humanizer"; import { join, relative } from "@visulima/path"; -import type { RunSummary, TaskFingerprint, TaskSummary } from "@visulima/task-runner"; -import { Cache, describeCacheMissReason, digestFile, getLastRunSummaryPath, parseCacheSize, readLastRunSummary } from "@visulima/task-runner"; +import type { TaskFingerprint } from "@visulima/task-runner"; +import { Cache, digestFile, getLastRunSummaryPath, readLastRunSummary } from "@visulima/task-runner"; -import { clearCache as clearAiResponseCache, getCacheStats as getAiCacheStats } from "../../ai/ai-cache"; -import { applyBranchScope, isCacheDirectoryInsideWorkspace, resolveSharedCacheDirectory } from "../../cache/cache-directory"; +import { getCacheStats as getAiCacheStats } from "../../ai/ai-cache"; import { pail } from "../../io/logger"; -import { diffHashDetails, findTaskInSummary, readPreviousRunSummary, readRunSummaryById } from "../../report/run-summary-utils"; -import type { HashDetailsDiff } from "../../report/types"; -import { clearDepsDevCache, getDepsDevCacheStats } from "../../security/deps-dev-security"; -import { clearSnykCache, getSnykCacheStats } from "../../security/snyk-security"; -import { clearSocketCache, getSocketCacheStats } from "../../security/socket-security"; +import { findTaskInSummary, readRunSummaryById } from "../../report/run-summary-utils"; +import { getDepsDevCacheStats } from "../../security/deps-dev-security"; +import { getSnykCacheStats } from "../../security/snyk-security"; +import { getSocketCacheStats } from "../../security/socket-security"; import { getVisRunsDir, getVisWorkspaceDataDir } from "../../util/vis-paths"; -import type { CacheCleanOptions, CacheHashOptions, CacheListOptions, CachePruneOptions, CacheSizeOptions, CacheVerifyOptions, CacheWhyOptions } from "./index"; - -/** - * Subdirectory `applyBranchScope` nests per-branch caches under. Reserved: - * it is a container, not a cache entry. - */ -const BRANCH_SCOPE_DIRECTORY = "branches"; - -/** - * Shape returned by `collectCacheEntries`. Kept close to what `removeOldEntries` - * uses internally so we can render the list identically. - */ -interface CacheEntry { - hash: string; - mtimeMs: number; - path: string; - sizeBytes: number; -} - -const sumDirectorySize = async (directory: string): Promise => { - let total = 0; - - try { - const entries = await readdir(directory, { withFileTypes: true }); - - for (const entry of entries) { - const full = join(directory, entry.name); - - if (entry.isDirectory()) { - total += await sumDirectorySize(full); - } else if (entry.isFile()) { - const s = await stat(full); - - total += s.size; - } - } - } catch { - // Ignore — permissions, concurrent deletes, etc. - } - - return total; -}; - -export const collectCacheEntries = async (cacheDirectory: string): Promise => { - const entries: CacheEntry[] = []; - - let dirents: string[]; - - try { - dirents = await readdir(cacheDirectory); - } catch { - return []; - } - - for (const name of dirents) { - // `branches` is the branch-scoping container, never a cache entry: - // its children are cache roots in their own right. Counting it as one - // entry reported N branches' worth of hashes as a single row, and - // listing it alongside those roots would double-count them. - if (name.startsWith(".") || name === BRANCH_SCOPE_DIRECTORY) { - continue; - } - - const fullPath = join(cacheDirectory, name); - - try { - const s = await stat(fullPath); - - if (!s.isDirectory()) { - continue; - } - - const sizeBytes = await sumDirectorySize(fullPath); - - entries.push({ - hash: name, - mtimeMs: s.mtimeMs, - path: fullPath, - sizeBytes, - }); - } catch { - // Ignore — entry may have been removed concurrently. - } - } - - entries.sort((a, b) => b.mtimeMs - a.mtimeMs); - - return entries; -}; - -export const formatAge = (mtimeMs: number, now: number = Date.now()): string => { - // Clamp future timestamps to 0 — clock skew or networked filesystems - // can produce mtimes ahead of `now`, and "-NNs" in the table is noise. - const seconds = Math.max(0, Math.floor((now - mtimeMs) / 1000)); - - if (seconds < 60) { - return `${String(seconds)}s`; - } - - if (seconds < 3600) { - return `${String(Math.floor(seconds / 60))}m`; - } - - if (seconds < 86_400) { - return `${String(Math.floor(seconds / 3600))}h`; - } - - return `${String(Math.floor(seconds / 86_400))}d`; -}; - -const isoOrNull = (value: number | undefined): string | null => (value === undefined ? null : new Date(value).toISOString()); - -/** - * Writes one line of report output verbatim. - * - * Deliberately not `pail.info`: the pretty reporter frames every call in a - * full-width timestamped rule plus a blank line, so a five-field stats - * block rendered as ~20 terminal lines and the numbers scrolled the - * directory path off screen. These lines are tabular data, not log events. - */ -const printLine = (line: string): void => { - process.stdout.write(`${line}\n`); -}; - -interface AuxStats { - /** Only the task cache has a configurable location worth printing. */ - directory?: string; - entries: number; - newestEntry: number | undefined; - oldestEntry: number | undefined; - totalSizeBytes: number; -} - -/** Renders one labelled cache-store block. Shared by every store `vis cache size` reports. */ -const printStatsBlock = (label: string, stats: AuxStats): void => { - printLine(`${label}:`); - - if (stats.directory !== undefined) { - printLine(` Directory: ${stats.directory}`); - } - - printLine(` Entries: ${String(stats.entries)}`); - printLine(` Total size: ${formatBytes(stats.totalSizeBytes, { decimals: 1, space: false })}`); - printLine(` Oldest: ${stats.oldestEntry ? new Date(stats.oldestEntry).toISOString() : "N/A"}`); - printLine(` Newest: ${stats.newestEntry ? new Date(stats.newestEntry).toISOString() : "N/A"}`); -}; - -/** - * Expands a cache root into the directories an eviction pass must walk. - * - * With branch scoping on, entries live in `<base>/branches/<slug>`; every - * such subtree counts against retention, including branches this checkout - * has never been on. - * - * `base` itself is always included, never replaced. A workspace that ran - * unscoped before turning `branchScopedCache` on still has entries sitting - * directly under it, and returning only the branch subtrees made those - * unreachable to `--max-age-days` / `--max-size` while `clean` still - * deleted them — retention silently stopped covering part of the store. - * `collectCacheEntries` skips the `branches` container, so listing `base` - * cannot double-count what the subtrees already report. - */ -const evictionDirectories = (base: string, branchScoped: boolean): string[] => { - if (!branchScoped) { - return [base]; - } - - const branchesRoot = join(base, BRANCH_SCOPE_DIRECTORY); - - if (!isAccessibleSync(branchesRoot)) { - return [base]; - } - - try { - const slugs = readdirSync(branchesRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(branchesRoot, entry.name)); - - return [base, ...slugs]; - } catch { - return [base]; - } -}; - -/** - * True when cache entries sit directly under `base`, outside any branch - * subtree — the layout a workspace has if it ran before `branchScopedCache` - * was turned on. - * - * Those entries are never *hit* once scoping is on, since `vis run` resolves - * to a branch subtree. But they are still on disk and still reclaimed by - * `prune`, so the reporting commands have to see them: `size` counting only - * the branch subtree made the store look smaller than what `prune` went on to - * free, which is the "reports less than it holds" discrepancy this command - * family exists to avoid. - */ -const hasUnscopedEntries = (base: string): boolean => { - if (!isAccessibleSync(base)) { - return false; - } - - try { - return readdirSync(base, { withFileTypes: true }).some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== BRANCH_SCOPE_DIRECTORY); - } catch { - return false; - } -}; - -const confirmPrompt = (question: string): Promise => - new Promise((resolve) => { - const rl = createInterface({ input: process.stdin, output: process.stderr }); - - rl.question(`${question} (y/N) `, (answer) => { - rl.close(); - const trimmed = answer.trim().toLowerCase(); - - resolve(trimmed === "y" || trimmed === "yes"); - }); - }); +import type { CacheHashOptions, CacheListOptions, CacheSizeOptions, CacheVerifyOptions } from "./index"; +import { collectCacheEntries, formatAge, includesTarget, isoOrNull, parseCacheTarget, printLine, printStatsBlock, resolveCacheDirectoryFromContext, resolveWorkspaceRoot } from "./shared"; +import { renderHashDetailsBucket, truncateHash, writeJsonLine } from "./why"; export const runList = async (cacheDirectory: string, format: string, logger: Console): Promise => { if (!isAccessibleSync(cacheDirectory)) { @@ -319,437 +99,6 @@ export const runList = async (cacheDirectory: string, format: string, logger: Co } }; -// Keep each clear independent — a failure in one store (e.g. EPERM on a -// stale lockfile) shouldn't prevent the others from being cleared, and -// neither should bubble up and abort `vis cache clean`. -export const clearAiCacheSafe = (): void => { - try { - const aiDeleted = clearAiResponseCache(); - - if (aiDeleted > 0) { - pail.info(`Cleared ${String(aiDeleted)} cached AI response${aiDeleted === 1 ? "" : "s"}.`); - } - } catch (error: unknown) { - pail.warn(`Failed to clear AI response cache: ${error instanceof Error ? error.message : String(error)}`); - } -}; - -export const clearSocketCacheSafe = (): void => { - try { - const socketDeleted = clearSocketCache(); - - if (socketDeleted > 0) { - pail.info(`Cleared ${String(socketDeleted)} cached Socket.dev report${socketDeleted === 1 ? "" : "s"}.`); - } - } catch (error: unknown) { - pail.warn(`Failed to clear Socket.dev cache: ${error instanceof Error ? error.message : String(error)}`); - } -}; - -export const clearDepsDevCacheSafe = (): void => { - try { - const depsDevDeleted = clearDepsDevCache(); - - if (depsDevDeleted > 0) { - pail.info(`Cleared ${String(depsDevDeleted)} cached deps.dev record${depsDevDeleted === 1 ? "" : "s"}.`); - } - } catch (error: unknown) { - pail.warn(`Failed to clear deps.dev cache: ${error instanceof Error ? error.message : String(error)}`); - } -}; - -export const clearSnykCacheSafe = (): void => { - try { - const snykDeleted = clearSnykCache(); - - if (snykDeleted > 0) { - pail.info(`Cleared ${String(snykDeleted)} cached Snyk record${snykDeleted === 1 ? "" : "s"}.`); - } - } catch (error: unknown) { - pail.warn(`Failed to clear Snyk cache: ${error instanceof Error ? error.message : String(error)}`); - } -}; - -export const runClean = async (cacheDirectory: string, workspaceRoot: string, options: { dryRun: boolean; force: boolean }): Promise => { - if (!isAccessibleSync(cacheDirectory)) { - pail.info(`No cache directory to clean at ${cacheDirectory}`); - - return; - } - - if (options.dryRun) { - // Count across every branch subtree, not just the top level. Under - // branch scoping the entries live one level down, so a plain read of - // the base saw the single `branches` directory and announced - // "Would remove 1 cache entry" for N branches' worth. The byte total - // was right all along, which made the count look plausible. - const perDirectory = await Promise.all(evictionDirectories(cacheDirectory, true).map(async (directory) => collectCacheEntries(directory))); - const entries = perDirectory.flat(); - const totalBytes = entries.reduce((sum, entry) => sum + entry.sizeBytes, 0); - - pail.info( - `Would remove ${String(entries.length)} cache entr${entries.length === 1 ? "y" : "ies"} ` - + `(${formatBytes(totalBytes, { decimals: 1, space: false })}) from ${cacheDirectory}`, - ); - - return; - } - - const insideWorkspace = isCacheDirectoryInsideWorkspace(cacheDirectory, workspaceRoot); - - try { - const realCache = await realpath(cacheDirectory); - const realWorkspace = await realpath(workspaceRoot); - - if (realCache === realWorkspace) { - pail.error("Refusing to delete the workspace root. The cache directory resolved to the same path as the workspace."); - process.exitCode = 1; - - return; - } - } catch { - // Path may not exist on disk; deletion branches handle missing paths safely. - } - - if (!insideWorkspace && !options.force) { - pail.warn(`Cache directory is outside the workspace root: ${cacheDirectory}`); - pail.warn("This will recursively delete the entire directory, including anything stored there by other tools."); - - if (!process.stdin.isTTY) { - pail.error("Refusing to clean an out-of-workspace cache without --force (stdin is not a TTY)."); - process.exitCode = 1; - - return; - } - - const confirmed = await confirmPrompt(" Continue?"); - - if (!confirmed) { - pail.info("Aborted."); - - return; - } - } - - if (insideWorkspace) { - const cache = new Cache({ cacheDirectory, workspaceRoot }); - - await cache.clear(); - } else { - await rm(cacheDirectory, { force: true, recursive: true }); - } - - pail.success(`Cleared cache: ${cacheDirectory}`); -}; - -export const runPrune = async ( - cacheDirectory: string, - workspaceRoot: string, - options: { keepLast?: number; maxCacheAgeDays?: number; maxCacheSize?: string }, -): Promise => { - if (!isAccessibleSync(cacheDirectory)) { - pail.info(`No cache directory to prune at ${cacheDirectory}`); - - return; - } - - if (options.maxCacheAgeDays !== undefined && (!Number.isFinite(options.maxCacheAgeDays) || options.maxCacheAgeDays < 0)) { - pail.error(`Invalid --max-age-days value: expected a finite number >= 0, got ${String(options.maxCacheAgeDays)}`); - process.exitCode = 1; - - return; - } - - if (options.keepLast !== undefined && (!Number.isFinite(options.keepLast) || options.keepLast < 0 || !Number.isInteger(options.keepLast))) { - pail.error(`Invalid --keep-last value: expected a non-negative integer, got ${String(options.keepLast)}`); - process.exitCode = 1; - - return; - } - - if (options.maxCacheSize !== undefined) { - let parsedBytes: number; - - try { - parsedBytes = parseCacheSize(options.maxCacheSize); - } catch (error: unknown) { - pail.error(`Invalid --max-size value: ${error instanceof Error ? error.message : String(error)}`); - process.exitCode = 1; - - return; - } - - if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { - pail.error(`Invalid --max-size value: expected a positive size, got "${options.maxCacheSize}" (${String(parsedBytes)} bytes)`); - process.exitCode = 1; - - return; - } - } - - const maxCacheAge = options.maxCacheAgeDays === undefined ? undefined : options.maxCacheAgeDays * 24 * 60 * 60 * 1000; - - const before = await collectCacheEntries(cacheDirectory); - const beforeBytes = before.reduce((sum, entry) => sum + entry.sizeBytes, 0); - - // --keep-last runs first: trim by recency before letting the - // task-runner Cache enforce age/size limits. This way `--keep-last 30` - // is an absolute floor — we never end up over 30 even if the surviving - // entries are also young or small. - if (options.keepLast !== undefined && before.length > options.keepLast) { - const stale = before.slice(options.keepLast); - - await Promise.all(stale.map((entry) => rm(entry.path, { force: true, recursive: true }))); - } - - const cache = new Cache({ - cacheDirectory, - maxCacheAge, - maxCacheSize: options.maxCacheSize, - workspaceRoot, - }); - - await cache.removeOldEntries(); - - const after = await collectCacheEntries(cacheDirectory); - const afterBytes = after.reduce((sum, entry) => sum + entry.sizeBytes, 0); - - const removed = before.length - after.length; - const reclaimedBytes = beforeBytes - afterBytes; - - if (removed <= 0) { - pail.info("Nothing to prune — all entries are within the configured limits."); - - return; - } - - pail.success(`Pruned ${String(removed)} entr${removed === 1 ? "y" : "ies"}, freed ${formatBytes(reclaimedBytes, { decimals: 1, space: false })}.`); -}; - -/** - * Hashes are long opaque blobs; show enough prefix to be diff-grep-able - * but not so much that columns wrap. The trailing `…` is load-bearing — - * without it the user can't tell whether two truncated values are - * equal or just share a prefix. - */ -const HASH_DISPLAY_PREFIX = 16; - -const truncateHash = (value: string): string => (value.length > HASH_DISPLAY_PREFIX ? `${value.slice(0, HASH_DISPLAY_PREFIX)}…` : value); - -const renderHashDetailsBucket = (label: string, bucket: Record | undefined, logger: Console): void => { - const entries = Object.entries(bucket ?? {}); - - if (entries.length === 0) { - return; - } - - logger.info(` ${label}:`); - entries.sort(([a], [b]) => a.localeCompare(b)); - - for (const [key, value] of entries) { - logger.info(` ${key.padEnd(40)} ${truncateHash(value)}`); - } -}; - -interface RunWhyOptions { - format: string; - runId: string | undefined; - workspaceRoot: string; -} - -const writeJsonLine = (payload: unknown): void => { - process.stdout.write(`${JSON.stringify(payload, undefined, 2)}\n`); -}; - -const reportWhyMissingSummary = (format: string, taskId: string, runId: string | undefined, workspaceRoot: string): void => { - if (format === "json") { - writeJsonLine({ error: "no-summary", runId: runId ?? null, taskId }); - } else if (runId === undefined) { - pail.error( - `No previous run summary found. Run a task first to populate \`${getLastRunSummaryPath(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) })}\`.`, - ); - } else { - pail.error(`Run summary "${runId}" not found in ${getVisRunsDir(workspaceRoot)}/.`); - } - - process.exitCode = 1; -}; - -const reportWhyTaskNotInSummary = (format: string, taskId: string, summary: RunSummary): void => { - if (format === "json") { - writeJsonLine({ error: "task-not-in-summary", runId: summary.id, taskId }); - } else { - pail.error(`Task "${taskId}" was not part of run ${summary.id}.`); - pail.info(`Tasks in this run: ${summary.tasks.map((t) => t.taskId).join(", ") || "(none)"}`); - } - - process.exitCode = 1; -}; - -const buildWhyJson = ( - summary: RunSummary, - task: TaskSummary, - previousSummary: RunSummary | undefined, - previousTask: TaskSummary | undefined, - diff: HashDetailsDiff, -): Record => { - return { - diff, - previousRunId: previousSummary?.id ?? null, - previousTask: previousTask - ? { - cacheStatus: previousTask.cacheStatus, - hash: previousTask.hash ?? null, - hashDetails: previousTask.hashDetails ?? null, - } - : null, - runId: summary.id, - task: { - cacheMissReasons: task.cacheMissReasons ?? null, - cacheStatus: task.cacheStatus, - hash: task.hash ?? null, - hashDetails: task.hashDetails ?? null, - hashSource: task.hashSource ?? null, - taskId: task.taskId, - }, - }; -}; - -const printWhyHeader = ( - taskId: string, - summary: RunSummary, - task: TaskSummary, - previousSummary: RunSummary | undefined, - previousTask: TaskSummary | undefined, - logger: Console, -): void => { - pail.info(`Why ${taskId}? (run ${summary.id})`); - logger.info(""); - logger.info(` status: ${task.cacheStatus}`); - logger.info(` hash: ${task.hash ?? "(none)"}`); - - if (task.hashSource === "declared-fallback") { - logger.info(" key: declared-fallback (tracker observed no workspace access; keyed on declared inputs)"); - } - - if (previousTask) { - logger.info(` prev: ${previousTask.hash ?? "(none)"} [run ${previousSummary?.id ?? "?"}]`); - } else { - logger.info(` prev: (no prior run found)`); - } - - logger.info(""); -}; - -const printTraceModeWhy = (taskId: string, summary: RunSummary, task: TaskSummary, logger: Console): void => { - // Trace-mode tasks keep no declared hash inputs, so the diff is empty by - // construction. Their explanation is the recorded miss reason. - if (task.cacheMissReasons && task.cacheMissReasons.length > 0) { - logger.info("Cache miss reasons (trace-mode fingerprint):"); - - for (const reason of task.cacheMissReasons) { - logger.info(` - ${describeCacheMissReason(reason)}`); - } - } else { - pail.info("No hash inputs recorded for this run (trace-mode tasks record a miss reason only when the cache misses)."); - } - - logger.info(""); - pail.info(`Tracked files and env of this run: vis cache hash ${taskId} --run ${summary.id}`); -}; - -const hasNoHashChanges = (diff: HashDetailsDiff): boolean => - !diff.commandChanged - && [diff.nodes, diff.runtime, diff.implicitDeps].every((bucket) => bucket.added.length === 0 && bucket.changed.length === 0 && bucket.removed.length === 0); - -const printHashInputChanges = (diff: HashDetailsDiff, logger: Console): void => { - logger.info("Hash inputs that changed since the previous run:"); - logger.info(""); - - if (diff.commandChanged) { - logger.info(" command: changed"); - } - - for (const bucket of ["nodes", "runtime", "implicitDeps"] as const) { - const bucketDiff = diff[bucket]; - - if (bucketDiff.added.length === 0 && bucketDiff.changed.length === 0 && bucketDiff.removed.length === 0) { - continue; - } - - logger.info(` ${bucket}:`); - - for (const key of bucketDiff.added) { - logger.info(` + ${key}`); - } - - for (const key of bucketDiff.changed) { - logger.info(` ~ ${key}`); - } - - for (const key of bucketDiff.removed) { - logger.info(` - ${key}`); - } - } -}; - -export const runWhy = async (taskId: string, options: RunWhyOptions, logger: Console): Promise => { - const { format, runId, workspaceRoot } = options; - - const summary - = runId === undefined - ? await readLastRunSummary(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) }) - : await readRunSummaryById(workspaceRoot, runId); - - if (!summary) { - reportWhyMissingSummary(format, taskId, runId, workspaceRoot); - - return; - } - - const task = findTaskInSummary(summary, taskId); - - if (!task) { - reportWhyTaskNotInSummary(format, taskId, summary); - - return; - } - - const previousSummary = await readPreviousRunSummary(workspaceRoot, summary.id); - const previousTask = previousSummary ? findTaskInSummary(previousSummary, taskId) : undefined; - const diff = diffHashDetails(task.hashDetails, previousTask?.hashDetails); - - if (format === "json") { - writeJsonLine(buildWhyJson(summary, task, previousSummary, previousTask, diff)); - - return; - } - - printWhyHeader(taskId, summary, task, previousSummary, previousTask, logger); - - if (task.hashDetails === undefined) { - printTraceModeWhy(taskId, summary, task, logger); - - return; - } - - if (!previousTask) { - pail.info("No previous run to diff against — first time this task was recorded."); - - return; - } - - if (hasNoHashChanges(diff)) { - pail.success("No hash inputs changed since the previous run."); - - return; - } - - printHashInputChanges(diff, logger); - logger.info(""); - pail.info(`Last summary file: ${getLastRunSummaryPath(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) })}`); -}; - interface RunHashOptions { /** Cache roots to read a trace-mode fingerprint from, in lookup order. */ cacheDirectories?: string[]; @@ -1224,151 +573,6 @@ export const runVerify = async (taskId: string, options: RunVerifyOptions, logge } }; -// Which cache stores does an operation touch? "task" is the workspace task -// runner cache (resolved with --scope/--cache-dir), "ai" is the global AI -// response cache under ~/.vis/cache/ai, "socket" is the Socket.dev report -// cache under ~/.vis/cache/socket-security, "deps-dev" is the Google -// deps.dev cache under ~/.vis/cache/deps-dev, "snyk" is the Snyk issue -// cache under ~/.vis/cache/snyk. "all" means every store. -type CacheTarget = "all" | "ai" | "deps-dev" | "snyk" | "socket" | "task"; - -const parseCacheTarget = (raw: string | undefined): CacheTarget => { - if (raw === "task" || raw === "ai" || raw === "socket" || raw === "deps-dev" || raw === "snyk" || raw === "all") { - return raw; - } - - if (raw && raw.length > 0) { - pail.warn(`Unknown --type value '${raw}'; falling back to 'all'.`); - } - - return "all"; -}; - -const includesTarget = (selected: CacheTarget, kind: Exclude): boolean => selected === "all" || selected === kind; - -type CacheScope = "all" | "shared" | "worktree"; - -const parseScope = (raw: string | undefined): CacheScope => { - if (raw === "worktree" || raw === "shared" || raw === "all") { - return raw; - } - - if (raw && raw.length > 0) { - pail.warn(`Unknown --scope value '${raw}'; falling back to 'shared'.`); - } - - return "shared"; -}; - -/** - * What a `vis cache` subcommand intends to do, which decides how branch - * scoping applies: - * - * - `read` — inspect what the current checkout would hit. Scoped to this - * branch, because that is where `vis run` writes. - * - `evict` — enforce retention. Must see *every* branch's entries: a - * branch-scoped `prune --max-size` would leave every other branch's - * subtree growing forever, with only the nuclear `clean` able to touch - * them. - * - `wipe` — remove everything. Clearing the cache means clearing the - * cache, not just the checked-out branch's slice of it. - */ -type CacheOperation = "evict" | "read" | "wipe"; - -interface ResolvedCacheContext { - /** - * Directories this operation should act on, deduplicated. - * - * Prefer this over the raw fields below — it is the one place the - * branch-scoping decision is made, so a new subcommand cannot pick the - * wrong one by reaching for a sibling field. - */ - directoriesFor: (operation: CacheOperation) => string[]; - scope: CacheScope; - sharedWorktreeCache: boolean | undefined; - workspaceRoot: string; -} - -const resolveCacheDirectoryFromContext = ( - workspaceRoot: string | undefined, - options: Record, - visConfig: Record | undefined, -): ResolvedCacheContext => { - const resolvedWorkspaceRoot = workspaceRoot ?? process.cwd(); - const cfg = (visConfig ?? {}) as { branchScopedCache?: boolean; sharedWorktreeCache?: boolean; taskRunner?: { cacheDirectory?: string } }; - const taskRunner = cfg.taskRunner ?? {}; - const scope = parseScope(options.scope as string | undefined); - const optionsCacheDir = options.cacheDir as string | undefined; - - // Worktree-local: this checkout's `node_modules/.cache/vis`. Disable - // worktree-share by passing `sharedWorktreeCache: false` so the - // resolver returns the literal workspace_root path. - const worktreeBase = resolveSharedCacheDirectory(resolvedWorkspaceRoot, optionsCacheDir, taskRunner.cacheDirectory, false); - - // Shared: the main worktree's cache (or workspace_root for primary checkouts). - const sharedBase = resolveSharedCacheDirectory(resolvedWorkspaceRoot, optionsCacheDir, taskRunner.cacheDirectory, cfg.sharedWorktreeCache); - - // `vis run` writes into `/branches/` when branch scoping is - // on (see `applyBranchScope`). Without the same hop here every `vis - // cache` subcommand inspected the empty base directory and reported a - // full cache as 0 entries. No-op when `branchScopedCache` isn't `true`. - const worktreeDirectory = applyBranchScope(worktreeBase, resolvedWorkspaceRoot, cfg.branchScopedCache); - const sharedDirectory = applyBranchScope(sharedBase, resolvedWorkspaceRoot, cfg.branchScopedCache); - - // Pair each selected root with its branch-scoped read location, so - // every operation can pick the form it needs from the same selection. - let selected: { base: string; scoped: string }[]; - - switch (scope) { - case "all": { - selected - = sharedDirectory === worktreeDirectory - ? [{ base: sharedBase, scoped: sharedDirectory }] - : [ - { base: sharedBase, scoped: sharedDirectory }, - { base: worktreeBase, scoped: worktreeDirectory }, - ]; - break; - } - case "worktree": { - selected = [{ base: worktreeBase, scoped: worktreeDirectory }]; - break; - } - default: { - selected = [{ base: sharedBase, scoped: sharedDirectory }]; - } - } - - const branchScoped = cfg.branchScopedCache === true; - - return { - directoriesFor: (operation: CacheOperation): string[] => { - if (operation === "read") { - // The branch subtree is where `vis run` reads and writes, so it - // leads. A base still holding pre-scoping entries is appended so - // the reporting commands account for every byte `prune` can - // reclaim — they are dead weight, but they are not invisible. - return [ - ...new Set( - selected.flatMap((entry) => - (entry.scoped !== entry.base && hasUnscopedEntries(entry.base) ? [entry.scoped, entry.base] : [entry.scoped]), - ), - ), - ]; - } - - if (operation === "wipe") { - return [...new Set(selected.map((entry) => entry.base))]; - } - - return [...new Set(selected.flatMap((entry) => evictionDirectories(entry.base, branchScoped)))]; - }, - scope, - sharedWorktreeCache: cfg.sharedWorktreeCache, - workspaceRoot: resolvedWorkspaceRoot, - }; -}; - export const cacheListExecute = async ({ logger, options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { const { directoriesFor } = resolveCacheDirectoryFromContext(wsRoot, options, visConfig as Record | undefined); const cacheDirectories = directoriesFor("read"); @@ -1383,113 +587,6 @@ export const cacheListExecute = async ({ logger, options, visConfig, workspaceRo } }; -export const cacheCleanExecute = async ({ options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { - const target = parseCacheTarget(options.type); - const dryRun = Boolean(options.dryRun); - - if (includesTarget(target, "task")) { - const { directoriesFor, workspaceRoot } = resolveCacheDirectoryFromContext(wsRoot, options, visConfig as Record | undefined); - - // `runClean` already invokes `isCacheDirectoryInsideWorkspace` and - // prompts for out-of-workspace targets — when the user is in a linked - // worktree and the resolver landed on the *main* worktree's cache, - // that path lives outside the current `workspaceRoot`, so the - // existing prompt naturally requires `--force` (or interactive - // confirmation) before nuking the shared store. - for (const directory of directoriesFor("wipe")) { - await runClean(directory, workspaceRoot, { - dryRun, - force: Boolean(options.force), - }); - } - } - - if (includesTarget(target, "ai")) { - if (dryRun) { - const stats = getAiCacheStats(); - - pail.info(`Would clear ${String(stats.entries)} cached AI response${stats.entries === 1 ? "" : "s"}.`); - } else { - clearAiCacheSafe(); - } - } - - if (includesTarget(target, "socket")) { - if (dryRun) { - const stats = getSocketCacheStats(); - - pail.info(`Would clear ${String(stats.entries)} cached Socket.dev report${stats.entries === 1 ? "" : "s"}.`); - } else { - clearSocketCacheSafe(); - } - } - - if (includesTarget(target, "deps-dev")) { - if (dryRun) { - const stats = getDepsDevCacheStats(); - - pail.info(`Would clear ${String(stats.entries)} cached deps.dev record${stats.entries === 1 ? "" : "s"}.`); - } else { - clearDepsDevCacheSafe(); - } - } - - if (includesTarget(target, "snyk")) { - if (dryRun) { - const stats = getSnykCacheStats(); - - pail.info(`Would clear ${String(stats.entries)} cached Snyk record${stats.entries === 1 ? "" : "s"}.`); - } else { - clearSnykCacheSafe(); - } - } -}; - -// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) -export const cachePruneExecute = async ({ options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { - const { directoriesFor, workspaceRoot } = resolveCacheDirectoryFromContext(wsRoot, options, visConfig as Record | undefined); - // "evict": retention has to see every branch's entries. Scoped to the - // checked-out branch, `--max-size` could never reclaim another branch's - // subtree, so the store grew one subtree per branch ever checked out and - // only the nuclear `clean` could touch them. - const cacheDirectories = directoriesFor("evict"); - - for (const directory of cacheDirectories) { - if (cacheDirectories.length > 1) { - pail.info(`# ${directory}`); - } - - await runPrune(directory, workspaceRoot, { - keepLast: typeof options.keepLast === "number" ? options.keepLast : undefined, - maxCacheAgeDays: typeof options.maxAgeDays === "number" ? options.maxAgeDays : undefined, - maxCacheSize: options.maxSize, - }); - } -}; - -const resolveWorkspaceRoot = (workspaceRoot: string | undefined): string => workspaceRoot ?? process.cwd(); - -export const cacheWhyExecute = async ({ argument, logger, options, workspaceRoot: wsRoot }: Toolbox): Promise => { - const taskId = argument[0]; - - if (!taskId) { - pail.error("No task ID specified. Usage: vis cache why :"); - process.exitCode = 1; - - return; - } - - await runWhy( - taskId, - { - format: options.format ?? "table", - runId: options.run, - workspaceRoot: resolveWorkspaceRoot(wsRoot), - }, - logger, - ); -}; - export const cacheHashExecute = async ({ argument, logger, options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { const taskId = argument[0]; @@ -1655,3 +752,9 @@ export const cacheVerifyExecute = async ({ logger, ); }; + +export { cacheCleanExecute, cachePruneExecute, runClean, runPrune } from "./maintenance"; +// Entry points moved to sibling modules. Re-exported here so existing importers of +// this module keep resolving them. +export { collectCacheEntries, formatAge } from "./shared"; +export { cacheWhyExecute, runWhy } from "./why"; diff --git a/packages/tooling/vis/src/commands/cache/index.ts b/packages/tooling/vis/src/commands/cache/index.ts index 95da4568..5e20c379 100644 --- a/packages/tooling/vis/src/commands/cache/index.ts +++ b/packages/tooling/vis/src/commands/cache/index.ts @@ -48,7 +48,7 @@ const cacheClean: Command = { ["vis cache clean --dry-run", "Preview what clean would remove"], ], group: "Workspace", - loader: lazyNamed(() => import("./handler"), "cacheCleanExecute"), + loader: lazyNamed(() => import("./maintenance"), "cacheCleanExecute"), name: "clean", options: [ cacheDirectoryOption, @@ -77,7 +77,7 @@ const cachePrune: Command = { ["vis cache prune --keep-last=30", "Keep only the 30 most recent entries"], ], group: "Workspace", - loader: lazyNamed(() => import("./handler"), "cachePruneExecute"), + loader: lazyNamed(() => import("./maintenance"), "cachePruneExecute"), name: "prune", options: [ cacheDirectoryOption, @@ -149,7 +149,7 @@ const cacheWhy: Command = { ["vis cache why @myorg/app:build --run 2026-04-28T...", "Inspect a specific historical run"], ], group: "Workspace", - loader: lazyNamed(() => import("./handler"), "cacheWhyExecute"), + loader: lazyNamed(() => import("./why"), "cacheWhyExecute"), name: "why", options: [formatOption, runOption], }; diff --git a/packages/tooling/vis/src/commands/cache/maintenance.ts b/packages/tooling/vis/src/commands/cache/maintenance.ts new file mode 100644 index 00000000..492db741 --- /dev/null +++ b/packages/tooling/vis/src/commands/cache/maintenance.ts @@ -0,0 +1,382 @@ +import { readdirSync } from "node:fs"; +import { realpath, rm } from "node:fs/promises"; +import { createInterface } from "node:readline"; + +import type { Toolbox } from "@visulima/cerebro"; +import { isAccessibleSync } from "@visulima/fs"; +import { formatBytes } from "@visulima/humanizer"; +import { join } from "@visulima/path"; +import { Cache, parseCacheSize } from "@visulima/task-runner"; + +import { clearCache as clearAiResponseCache, getCacheStats as getAiCacheStats } from "../../ai/ai-cache"; +import { isCacheDirectoryInsideWorkspace } from "../../cache/cache-directory"; +import { pail } from "../../io/logger"; +import { clearDepsDevCache, getDepsDevCacheStats } from "../../security/deps-dev-security"; +import { clearSnykCache, getSnykCacheStats } from "../../security/snyk-security"; +import { clearSocketCache, getSocketCacheStats } from "../../security/socket-security"; +import type { CacheCleanOptions, CachePruneOptions } from "./index"; +import { BRANCH_SCOPE_DIRECTORY, collectCacheEntries, includesTarget, parseCacheTarget, resolveCacheDirectoryFromContext } from "./shared"; + +/** + * Expands a cache root into the directories an eviction pass must walk. + * + * With branch scoping on, entries live in `<base>/branches/<slug>`; every + * such subtree counts against retention, including branches this checkout + * has never been on. + * + * `base` itself is always included, never replaced. A workspace that ran + * unscoped before turning `branchScopedCache` on still has entries sitting + * directly under it, and returning only the branch subtrees made those + * unreachable to `--max-age-days` / `--max-size` while `clean` still + * deleted them — retention silently stopped covering part of the store. + * `collectCacheEntries` skips the `branches` container, so listing `base` + * cannot double-count what the subtrees already report. + */ +export const evictionDirectories = (base: string, branchScoped: boolean): string[] => { + if (!branchScoped) { + return [base]; + } + + const branchesRoot = join(base, BRANCH_SCOPE_DIRECTORY); + + if (!isAccessibleSync(branchesRoot)) { + return [base]; + } + + try { + const slugs = readdirSync(branchesRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(branchesRoot, entry.name)); + + return [base, ...slugs]; + } catch { + return [base]; + } +}; + +/** + * True when cache entries sit directly under `base`, outside any branch + * subtree — the layout a workspace has if it ran before `branchScopedCache` + * was turned on. + * + * Those entries are never *hit* once scoping is on, since `vis run` resolves + * to a branch subtree. But they are still on disk and still reclaimed by + * `prune`, so the reporting commands have to see them: `size` counting only + * the branch subtree made the store look smaller than what `prune` went on to + * free, which is the "reports less than it holds" discrepancy this command + * family exists to avoid. + */ +export const hasUnscopedEntries = (base: string): boolean => { + if (!isAccessibleSync(base)) { + return false; + } + + try { + return readdirSync(base, { withFileTypes: true }).some((entry) => entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== BRANCH_SCOPE_DIRECTORY); + } catch { + return false; + } +}; + +const confirmPrompt = (question: string): Promise => + new Promise((resolve) => { + const rl = createInterface({ input: process.stdin, output: process.stderr }); + + rl.question(`${question} (y/N) `, (answer) => { + rl.close(); + const trimmed = answer.trim().toLowerCase(); + + resolve(trimmed === "y" || trimmed === "yes"); + }); + }); + +// Keep each clear independent — a failure in one store (e.g. EPERM on a +// stale lockfile) shouldn't prevent the others from being cleared, and +// neither should bubble up and abort `vis cache clean`. +export const clearAiCacheSafe = (): void => { + try { + const aiDeleted = clearAiResponseCache(); + + if (aiDeleted > 0) { + pail.info(`Cleared ${String(aiDeleted)} cached AI response${aiDeleted === 1 ? "" : "s"}.`); + } + } catch (error: unknown) { + pail.warn(`Failed to clear AI response cache: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const clearSocketCacheSafe = (): void => { + try { + const socketDeleted = clearSocketCache(); + + if (socketDeleted > 0) { + pail.info(`Cleared ${String(socketDeleted)} cached Socket.dev report${socketDeleted === 1 ? "" : "s"}.`); + } + } catch (error: unknown) { + pail.warn(`Failed to clear Socket.dev cache: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const clearDepsDevCacheSafe = (): void => { + try { + const depsDevDeleted = clearDepsDevCache(); + + if (depsDevDeleted > 0) { + pail.info(`Cleared ${String(depsDevDeleted)} cached deps.dev record${depsDevDeleted === 1 ? "" : "s"}.`); + } + } catch (error: unknown) { + pail.warn(`Failed to clear deps.dev cache: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const clearSnykCacheSafe = (): void => { + try { + const snykDeleted = clearSnykCache(); + + if (snykDeleted > 0) { + pail.info(`Cleared ${String(snykDeleted)} cached Snyk record${snykDeleted === 1 ? "" : "s"}.`); + } + } catch (error: unknown) { + pail.warn(`Failed to clear Snyk cache: ${error instanceof Error ? error.message : String(error)}`); + } +}; + +export const runClean = async (cacheDirectory: string, workspaceRoot: string, options: { dryRun: boolean; force: boolean }): Promise => { + if (!isAccessibleSync(cacheDirectory)) { + pail.info(`No cache directory to clean at ${cacheDirectory}`); + + return; + } + + if (options.dryRun) { + // Count across every branch subtree, not just the top level. Under + // branch scoping the entries live one level down, so a plain read of + // the base saw the single `branches` directory and announced + // "Would remove 1 cache entry" for N branches' worth. The byte total + // was right all along, which made the count look plausible. + const perDirectory = await Promise.all(evictionDirectories(cacheDirectory, true).map(async (directory) => collectCacheEntries(directory))); + const entries = perDirectory.flat(); + const totalBytes = entries.reduce((sum, entry) => sum + entry.sizeBytes, 0); + + pail.info( + `Would remove ${String(entries.length)} cache entr${entries.length === 1 ? "y" : "ies"} ` + + `(${formatBytes(totalBytes, { decimals: 1, space: false })}) from ${cacheDirectory}`, + ); + + return; + } + + const insideWorkspace = isCacheDirectoryInsideWorkspace(cacheDirectory, workspaceRoot); + + try { + const realCache = await realpath(cacheDirectory); + const realWorkspace = await realpath(workspaceRoot); + + if (realCache === realWorkspace) { + pail.error("Refusing to delete the workspace root. The cache directory resolved to the same path as the workspace."); + process.exitCode = 1; + + return; + } + } catch { + // Path may not exist on disk; deletion branches handle missing paths safely. + } + + if (!insideWorkspace && !options.force) { + pail.warn(`Cache directory is outside the workspace root: ${cacheDirectory}`); + pail.warn("This will recursively delete the entire directory, including anything stored there by other tools."); + + if (!process.stdin.isTTY) { + pail.error("Refusing to clean an out-of-workspace cache without --force (stdin is not a TTY)."); + process.exitCode = 1; + + return; + } + + const confirmed = await confirmPrompt(" Continue?"); + + if (!confirmed) { + pail.info("Aborted."); + + return; + } + } + + if (insideWorkspace) { + const cache = new Cache({ cacheDirectory, workspaceRoot }); + + await cache.clear(); + } else { + await rm(cacheDirectory, { force: true, recursive: true }); + } + + pail.success(`Cleared cache: ${cacheDirectory}`); +}; + +export const runPrune = async ( + cacheDirectory: string, + workspaceRoot: string, + options: { keepLast?: number; maxCacheAgeDays?: number; maxCacheSize?: string }, +): Promise => { + if (!isAccessibleSync(cacheDirectory)) { + pail.info(`No cache directory to prune at ${cacheDirectory}`); + + return; + } + + if (options.maxCacheAgeDays !== undefined && (!Number.isFinite(options.maxCacheAgeDays) || options.maxCacheAgeDays < 0)) { + pail.error(`Invalid --max-age-days value: expected a finite number >= 0, got ${String(options.maxCacheAgeDays)}`); + process.exitCode = 1; + + return; + } + + if (options.keepLast !== undefined && (!Number.isFinite(options.keepLast) || options.keepLast < 0 || !Number.isInteger(options.keepLast))) { + pail.error(`Invalid --keep-last value: expected a non-negative integer, got ${String(options.keepLast)}`); + process.exitCode = 1; + + return; + } + + if (options.maxCacheSize !== undefined) { + let parsedBytes: number; + + try { + parsedBytes = parseCacheSize(options.maxCacheSize); + } catch (error: unknown) { + pail.error(`Invalid --max-size value: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + + return; + } + + if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) { + pail.error(`Invalid --max-size value: expected a positive size, got "${options.maxCacheSize}" (${String(parsedBytes)} bytes)`); + process.exitCode = 1; + + return; + } + } + + const maxCacheAge = options.maxCacheAgeDays === undefined ? undefined : options.maxCacheAgeDays * 24 * 60 * 60 * 1000; + + const before = await collectCacheEntries(cacheDirectory); + const beforeBytes = before.reduce((sum, entry) => sum + entry.sizeBytes, 0); + + // --keep-last runs first: trim by recency before letting the + // task-runner Cache enforce age/size limits. This way `--keep-last 30` + // is an absolute floor — we never end up over 30 even if the surviving + // entries are also young or small. + if (options.keepLast !== undefined && before.length > options.keepLast) { + const stale = before.slice(options.keepLast); + + await Promise.all(stale.map((entry) => rm(entry.path, { force: true, recursive: true }))); + } + + const cache = new Cache({ + cacheDirectory, + maxCacheAge, + maxCacheSize: options.maxCacheSize, + workspaceRoot, + }); + + await cache.removeOldEntries(); + + const after = await collectCacheEntries(cacheDirectory); + const afterBytes = after.reduce((sum, entry) => sum + entry.sizeBytes, 0); + + const removed = before.length - after.length; + const reclaimedBytes = beforeBytes - afterBytes; + + if (removed <= 0) { + pail.info("Nothing to prune — all entries are within the configured limits."); + + return; + } + + pail.success(`Pruned ${String(removed)} entr${removed === 1 ? "y" : "ies"}, freed ${formatBytes(reclaimedBytes, { decimals: 1, space: false })}.`); +}; + +export const cacheCleanExecute = async ({ options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { + const target = parseCacheTarget(options.type); + const dryRun = Boolean(options.dryRun); + + if (includesTarget(target, "task")) { + const { directoriesFor, workspaceRoot } = resolveCacheDirectoryFromContext(wsRoot, options, visConfig as Record | undefined); + + // `runClean` already invokes `isCacheDirectoryInsideWorkspace` and + // prompts for out-of-workspace targets — when the user is in a linked + // worktree and the resolver landed on the *main* worktree's cache, + // that path lives outside the current `workspaceRoot`, so the + // existing prompt naturally requires `--force` (or interactive + // confirmation) before nuking the shared store. + for (const directory of directoriesFor("wipe")) { + await runClean(directory, workspaceRoot, { + dryRun, + force: Boolean(options.force), + }); + } + } + + if (includesTarget(target, "ai")) { + if (dryRun) { + const stats = getAiCacheStats(); + + pail.info(`Would clear ${String(stats.entries)} cached AI response${stats.entries === 1 ? "" : "s"}.`); + } else { + clearAiCacheSafe(); + } + } + + if (includesTarget(target, "socket")) { + if (dryRun) { + const stats = getSocketCacheStats(); + + pail.info(`Would clear ${String(stats.entries)} cached Socket.dev report${stats.entries === 1 ? "" : "s"}.`); + } else { + clearSocketCacheSafe(); + } + } + + if (includesTarget(target, "deps-dev")) { + if (dryRun) { + const stats = getDepsDevCacheStats(); + + pail.info(`Would clear ${String(stats.entries)} cached deps.dev record${stats.entries === 1 ? "" : "s"}.`); + } else { + clearDepsDevCacheSafe(); + } + } + + if (includesTarget(target, "snyk")) { + if (dryRun) { + const stats = getSnykCacheStats(); + + pail.info(`Would clear ${String(stats.entries)} cached Snyk record${stats.entries === 1 ? "" : "s"}.`); + } else { + clearSnykCacheSafe(); + } + } +}; + +// fallow-ignore-next-line unused-export -- lazy-loaded command entry (cerebro loader/lazyNamed dynamic import) +export const cachePruneExecute = async ({ options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { + const { directoriesFor, workspaceRoot } = resolveCacheDirectoryFromContext(wsRoot, options, visConfig as Record | undefined); + // "evict": retention has to see every branch's entries. Scoped to the + // checked-out branch, `--max-size` could never reclaim another branch's + // subtree, so the store grew one subtree per branch ever checked out and + // only the nuclear `clean` could touch them. + const cacheDirectories = directoriesFor("evict"); + + for (const directory of cacheDirectories) { + if (cacheDirectories.length > 1) { + pail.info(`# ${directory}`); + } + + await runPrune(directory, workspaceRoot, { + keepLast: typeof options.keepLast === "number" ? options.keepLast : undefined, + maxCacheAgeDays: typeof options.maxAgeDays === "number" ? options.maxAgeDays : undefined, + maxCacheSize: options.maxSize, + }); + } +}; diff --git a/packages/tooling/vis/src/commands/cache/shared.ts b/packages/tooling/vis/src/commands/cache/shared.ts new file mode 100644 index 00000000..379f87e9 --- /dev/null +++ b/packages/tooling/vis/src/commands/cache/shared.ts @@ -0,0 +1,300 @@ +import { readdir, stat } from "node:fs/promises"; + +import { formatBytes } from "@visulima/humanizer"; +import { join } from "@visulima/path"; + +import { applyBranchScope, resolveSharedCacheDirectory } from "../../cache/cache-directory"; +import { pail } from "../../io/logger"; +import { evictionDirectories, hasUnscopedEntries } from "./maintenance"; + +/** + * Subdirectory `applyBranchScope` nests per-branch caches under. Reserved: + * it is a container, not a cache entry. + */ +export const BRANCH_SCOPE_DIRECTORY = "branches"; + +/** + * Shape returned by `collectCacheEntries`. Kept close to what `removeOldEntries` + * uses internally so we can render the list identically. + */ +interface CacheEntry { + hash: string; + mtimeMs: number; + path: string; + sizeBytes: number; +} + +const sumDirectorySize = async (directory: string): Promise => { + let total = 0; + + try { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const full = join(directory, entry.name); + + if (entry.isDirectory()) { + total += await sumDirectorySize(full); + } else if (entry.isFile()) { + const s = await stat(full); + + total += s.size; + } + } + } catch { + // Ignore — permissions, concurrent deletes, etc. + } + + return total; +}; + +export const collectCacheEntries = async (cacheDirectory: string): Promise => { + const entries: CacheEntry[] = []; + + let dirents: string[]; + + try { + dirents = await readdir(cacheDirectory); + } catch { + return []; + } + + for (const name of dirents) { + // `branches` is the branch-scoping container, never a cache entry: + // its children are cache roots in their own right. Counting it as one + // entry reported N branches' worth of hashes as a single row, and + // listing it alongside those roots would double-count them. + if (name.startsWith(".") || name === BRANCH_SCOPE_DIRECTORY) { + continue; + } + + const fullPath = join(cacheDirectory, name); + + try { + const s = await stat(fullPath); + + if (!s.isDirectory()) { + continue; + } + + const sizeBytes = await sumDirectorySize(fullPath); + + entries.push({ + hash: name, + mtimeMs: s.mtimeMs, + path: fullPath, + sizeBytes, + }); + } catch { + // Ignore — entry may have been removed concurrently. + } + } + + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + return entries; +}; + +export const formatAge = (mtimeMs: number, now: number = Date.now()): string => { + // Clamp future timestamps to 0 — clock skew or networked filesystems + // can produce mtimes ahead of `now`, and "-NNs" in the table is noise. + const seconds = Math.max(0, Math.floor((now - mtimeMs) / 1000)); + + if (seconds < 60) { + return `${String(seconds)}s`; + } + + if (seconds < 3600) { + return `${String(Math.floor(seconds / 60))}m`; + } + + if (seconds < 86_400) { + return `${String(Math.floor(seconds / 3600))}h`; + } + + return `${String(Math.floor(seconds / 86_400))}d`; +}; + +export const isoOrNull = (value: number | undefined): string | null => (value === undefined ? null : new Date(value).toISOString()); + +/** + * Writes one line of report output verbatim. + * + * Deliberately not `pail.info`: the pretty reporter frames every call in a + * full-width timestamped rule plus a blank line, so a five-field stats + * block rendered as ~20 terminal lines and the numbers scrolled the + * directory path off screen. These lines are tabular data, not log events. + */ +export const printLine = (line: string): void => { + process.stdout.write(`${line}\n`); +}; + +interface AuxStats { + /** Only the task cache has a configurable location worth printing. */ + directory?: string; + entries: number; + newestEntry: number | undefined; + oldestEntry: number | undefined; + totalSizeBytes: number; +} + +/** Renders one labelled cache-store block. Shared by every store `vis cache size` reports. */ +export const printStatsBlock = (label: string, stats: AuxStats): void => { + printLine(`${label}:`); + + if (stats.directory !== undefined) { + printLine(` Directory: ${stats.directory}`); + } + + printLine(` Entries: ${String(stats.entries)}`); + printLine(` Total size: ${formatBytes(stats.totalSizeBytes, { decimals: 1, space: false })}`); + printLine(` Oldest: ${stats.oldestEntry ? new Date(stats.oldestEntry).toISOString() : "N/A"}`); + printLine(` Newest: ${stats.newestEntry ? new Date(stats.newestEntry).toISOString() : "N/A"}`); +}; + +// Which cache stores does an operation touch? "task" is the workspace task +// runner cache (resolved with --scope/--cache-dir), "ai" is the global AI +// response cache under ~/.vis/cache/ai, "socket" is the Socket.dev report +// cache under ~/.vis/cache/socket-security, "deps-dev" is the Google +// deps.dev cache under ~/.vis/cache/deps-dev, "snyk" is the Snyk issue +// cache under ~/.vis/cache/snyk. "all" means every store. +type CacheTarget = "all" | "ai" | "deps-dev" | "snyk" | "socket" | "task"; + +export const parseCacheTarget = (raw: string | undefined): CacheTarget => { + if (raw === "task" || raw === "ai" || raw === "socket" || raw === "deps-dev" || raw === "snyk" || raw === "all") { + return raw; + } + + if (raw && raw.length > 0) { + pail.warn(`Unknown --type value '${raw}'; falling back to 'all'.`); + } + + return "all"; +}; + +export const includesTarget = (selected: CacheTarget, kind: Exclude): boolean => selected === "all" || selected === kind; + +type CacheScope = "all" | "shared" | "worktree"; + +const parseScope = (raw: string | undefined): CacheScope => { + if (raw === "worktree" || raw === "shared" || raw === "all") { + return raw; + } + + if (raw && raw.length > 0) { + pail.warn(`Unknown --scope value '${raw}'; falling back to 'shared'.`); + } + + return "shared"; +}; + +/** + * What a `vis cache` subcommand intends to do, which decides how branch + * scoping applies: + * + * - `read` — inspect what the current checkout would hit. Scoped to this + * branch, because that is where `vis run` writes. + * - `evict` — enforce retention. Must see *every* branch's entries: a + * branch-scoped `prune --max-size` would leave every other branch's + * subtree growing forever, with only the nuclear `clean` able to touch + * them. + * - `wipe` — remove everything. Clearing the cache means clearing the + * cache, not just the checked-out branch's slice of it. + */ +type CacheOperation = "evict" | "read" | "wipe"; + +interface ResolvedCacheContext { + /** + * Directories this operation should act on, deduplicated. + * + * Prefer this over the raw fields below — it is the one place the + * branch-scoping decision is made, so a new subcommand cannot pick the + * wrong one by reaching for a sibling field. + */ + directoriesFor: (operation: CacheOperation) => string[]; + scope: CacheScope; + sharedWorktreeCache: boolean | undefined; + workspaceRoot: string; +} + +export const resolveCacheDirectoryFromContext = ( + workspaceRoot: string | undefined, + options: Record, + visConfig: Record | undefined, +): ResolvedCacheContext => { + const resolvedWorkspaceRoot = workspaceRoot ?? process.cwd(); + const cfg = (visConfig ?? {}) as { branchScopedCache?: boolean; sharedWorktreeCache?: boolean; taskRunner?: { cacheDirectory?: string } }; + const taskRunner = cfg.taskRunner ?? {}; + const scope = parseScope(options.scope as string | undefined); + const optionsCacheDir = options.cacheDir as string | undefined; + + // Worktree-local: this checkout's `node_modules/.cache/vis`. Disable + // worktree-share by passing `sharedWorktreeCache: false` so the + // resolver returns the literal workspace_root path. + const worktreeBase = resolveSharedCacheDirectory(resolvedWorkspaceRoot, optionsCacheDir, taskRunner.cacheDirectory, false); + + // Shared: the main worktree's cache (or workspace_root for primary checkouts). + const sharedBase = resolveSharedCacheDirectory(resolvedWorkspaceRoot, optionsCacheDir, taskRunner.cacheDirectory, cfg.sharedWorktreeCache); + + // `vis run` writes into `/branches/` when branch scoping is + // on (see `applyBranchScope`). Without the same hop here every `vis + // cache` subcommand inspected the empty base directory and reported a + // full cache as 0 entries. No-op when `branchScopedCache` isn't `true`. + const worktreeDirectory = applyBranchScope(worktreeBase, resolvedWorkspaceRoot, cfg.branchScopedCache); + const sharedDirectory = applyBranchScope(sharedBase, resolvedWorkspaceRoot, cfg.branchScopedCache); + + // Pair each selected root with its branch-scoped read location, so + // every operation can pick the form it needs from the same selection. + let selected: { base: string; scoped: string }[]; + + switch (scope) { + case "all": { + selected + = sharedDirectory === worktreeDirectory + ? [{ base: sharedBase, scoped: sharedDirectory }] + : [ + { base: sharedBase, scoped: sharedDirectory }, + { base: worktreeBase, scoped: worktreeDirectory }, + ]; + break; + } + case "worktree": { + selected = [{ base: worktreeBase, scoped: worktreeDirectory }]; + break; + } + default: { + selected = [{ base: sharedBase, scoped: sharedDirectory }]; + } + } + + const branchScoped = cfg.branchScopedCache === true; + + return { + directoriesFor: (operation: CacheOperation): string[] => { + if (operation === "read") { + // The branch subtree is where `vis run` reads and writes, so it + // leads. A base still holding pre-scoping entries is appended so + // the reporting commands account for every byte `prune` can + // reclaim — they are dead weight, but they are not invisible. + return [ + ...new Set( + selected.flatMap((entry) => + (entry.scoped !== entry.base && hasUnscopedEntries(entry.base) ? [entry.scoped, entry.base] : [entry.scoped]), + ), + ), + ]; + } + + if (operation === "wipe") { + return [...new Set(selected.map((entry) => entry.base))]; + } + + return [...new Set(selected.flatMap((entry) => evictionDirectories(entry.base, branchScoped)))]; + }, + scope, + sharedWorktreeCache: cfg.sharedWorktreeCache, + workspaceRoot: resolvedWorkspaceRoot, + }; +}; + +export const resolveWorkspaceRoot = (workspaceRoot: string | undefined): string => workspaceRoot ?? process.cwd(); diff --git a/packages/tooling/vis/src/commands/cache/why.ts b/packages/tooling/vis/src/commands/cache/why.ts new file mode 100644 index 00000000..3d8c90e3 --- /dev/null +++ b/packages/tooling/vis/src/commands/cache/why.ts @@ -0,0 +1,255 @@ +import type { Toolbox } from "@visulima/cerebro"; +import type { RunSummary, TaskSummary } from "@visulima/task-runner"; +import { describeCacheMissReason, getLastRunSummaryPath, readLastRunSummary } from "@visulima/task-runner"; + +import { pail } from "../../io/logger"; +import { diffHashDetails, findTaskInSummary, readPreviousRunSummary, readRunSummaryById } from "../../report/run-summary-utils"; +import type { HashDetailsDiff } from "../../report/types"; +import { getVisRunsDir, getVisWorkspaceDataDir } from "../../util/vis-paths"; +import type { CacheWhyOptions } from "./index"; +import { resolveWorkspaceRoot } from "./shared"; + +/** + * Hashes are long opaque blobs; show enough prefix to be diff-grep-able + * but not so much that columns wrap. The trailing `…` is load-bearing — + * without it the user can't tell whether two truncated values are + * equal or just share a prefix. + */ +const HASH_DISPLAY_PREFIX = 16; + +export const truncateHash = (value: string): string => (value.length > HASH_DISPLAY_PREFIX ? `${value.slice(0, HASH_DISPLAY_PREFIX)}…` : value); + +export const renderHashDetailsBucket = (label: string, bucket: Record | undefined, logger: Console): void => { + const entries = Object.entries(bucket ?? {}); + + if (entries.length === 0) { + return; + } + + logger.info(` ${label}:`); + entries.sort(([a], [b]) => a.localeCompare(b)); + + for (const [key, value] of entries) { + logger.info(` ${key.padEnd(40)} ${truncateHash(value)}`); + } +}; + +interface RunWhyOptions { + format: string; + runId: string | undefined; + workspaceRoot: string; +} + +export const writeJsonLine = (payload: unknown): void => { + process.stdout.write(`${JSON.stringify(payload, undefined, 2)}\n`); +}; + +const reportWhyMissingSummary = (format: string, taskId: string, runId: string | undefined, workspaceRoot: string): void => { + if (format === "json") { + writeJsonLine({ error: "no-summary", runId: runId ?? null, taskId }); + } else if (runId === undefined) { + pail.error( + `No previous run summary found. Run a task first to populate \`${getLastRunSummaryPath(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) })}\`.`, + ); + } else { + pail.error(`Run summary "${runId}" not found in ${getVisRunsDir(workspaceRoot)}/.`); + } + + process.exitCode = 1; +}; + +const reportWhyTaskNotInSummary = (format: string, taskId: string, summary: RunSummary): void => { + if (format === "json") { + writeJsonLine({ error: "task-not-in-summary", runId: summary.id, taskId }); + } else { + pail.error(`Task "${taskId}" was not part of run ${summary.id}.`); + pail.info(`Tasks in this run: ${summary.tasks.map((t) => t.taskId).join(", ") || "(none)"}`); + } + + process.exitCode = 1; +}; + +const buildWhyJson = ( + summary: RunSummary, + task: TaskSummary, + previousSummary: RunSummary | undefined, + previousTask: TaskSummary | undefined, + diff: HashDetailsDiff, +): Record => { + return { + diff, + previousRunId: previousSummary?.id ?? null, + previousTask: previousTask + ? { + cacheStatus: previousTask.cacheStatus, + hash: previousTask.hash ?? null, + hashDetails: previousTask.hashDetails ?? null, + } + : null, + runId: summary.id, + task: { + cacheMissReasons: task.cacheMissReasons ?? null, + cacheStatus: task.cacheStatus, + hash: task.hash ?? null, + hashDetails: task.hashDetails ?? null, + hashSource: task.hashSource ?? null, + taskId: task.taskId, + }, + }; +}; + +const printWhyHeader = ( + taskId: string, + summary: RunSummary, + task: TaskSummary, + previousSummary: RunSummary | undefined, + previousTask: TaskSummary | undefined, + logger: Console, +): void => { + pail.info(`Why ${taskId}? (run ${summary.id})`); + logger.info(""); + logger.info(` status: ${task.cacheStatus}`); + logger.info(` hash: ${task.hash ?? "(none)"}`); + + if (task.hashSource === "declared-fallback") { + logger.info(" key: declared-fallback (tracker observed no workspace access; keyed on declared inputs)"); + } + + if (previousTask) { + logger.info(` prev: ${previousTask.hash ?? "(none)"} [run ${previousSummary?.id ?? "?"}]`); + } else { + logger.info(` prev: (no prior run found)`); + } + + logger.info(""); +}; + +const printTraceModeWhy = (taskId: string, summary: RunSummary, task: TaskSummary, logger: Console): void => { + // Trace-mode tasks keep no declared hash inputs, so the diff is empty by + // construction. Their explanation is the recorded miss reason. + if (task.cacheMissReasons && task.cacheMissReasons.length > 0) { + logger.info("Cache miss reasons (trace-mode fingerprint):"); + + for (const reason of task.cacheMissReasons) { + logger.info(` - ${describeCacheMissReason(reason)}`); + } + } else { + pail.info("No hash inputs recorded for this run (trace-mode tasks record a miss reason only when the cache misses)."); + } + + logger.info(""); + pail.info(`Tracked files and env of this run: vis cache hash ${taskId} --run ${summary.id}`); +}; + +const hasNoHashChanges = (diff: HashDetailsDiff): boolean => + !diff.commandChanged + && [diff.nodes, diff.runtime, diff.implicitDeps].every((bucket) => bucket.added.length === 0 && bucket.changed.length === 0 && bucket.removed.length === 0); + +const printHashInputChanges = (diff: HashDetailsDiff, logger: Console): void => { + logger.info("Hash inputs that changed since the previous run:"); + logger.info(""); + + if (diff.commandChanged) { + logger.info(" command: changed"); + } + + for (const bucket of ["nodes", "runtime", "implicitDeps"] as const) { + const bucketDiff = diff[bucket]; + + if (bucketDiff.added.length === 0 && bucketDiff.changed.length === 0 && bucketDiff.removed.length === 0) { + continue; + } + + logger.info(` ${bucket}:`); + + for (const key of bucketDiff.added) { + logger.info(` + ${key}`); + } + + for (const key of bucketDiff.changed) { + logger.info(` ~ ${key}`); + } + + for (const key of bucketDiff.removed) { + logger.info(` - ${key}`); + } + } +}; + +export const runWhy = async (taskId: string, options: RunWhyOptions, logger: Console): Promise => { + const { format, runId, workspaceRoot } = options; + + const summary + = runId === undefined + ? await readLastRunSummary(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) }) + : await readRunSummaryById(workspaceRoot, runId); + + if (!summary) { + reportWhyMissingSummary(format, taskId, runId, workspaceRoot); + + return; + } + + const task = findTaskInSummary(summary, taskId); + + if (!task) { + reportWhyTaskNotInSummary(format, taskId, summary); + + return; + } + + const previousSummary = await readPreviousRunSummary(workspaceRoot, summary.id); + const previousTask = previousSummary ? findTaskInSummary(previousSummary, taskId) : undefined; + const diff = diffHashDetails(task.hashDetails, previousTask?.hashDetails); + + if (format === "json") { + writeJsonLine(buildWhyJson(summary, task, previousSummary, previousTask, diff)); + + return; + } + + printWhyHeader(taskId, summary, task, previousSummary, previousTask, logger); + + if (task.hashDetails === undefined) { + printTraceModeWhy(taskId, summary, task, logger); + + return; + } + + if (!previousTask) { + pail.info("No previous run to diff against — first time this task was recorded."); + + return; + } + + if (hasNoHashChanges(diff)) { + pail.success("No hash inputs changed since the previous run."); + + return; + } + + printHashInputChanges(diff, logger); + logger.info(""); + pail.info(`Last summary file: ${getLastRunSummaryPath(workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) })}`); +}; + +export const cacheWhyExecute = async ({ argument, logger, options, workspaceRoot: wsRoot }: Toolbox): Promise => { + const taskId = argument[0]; + + if (!taskId) { + pail.error("No task ID specified. Usage: vis cache why :"); + process.exitCode = 1; + + return; + } + + await runWhy( + taskId, + { + format: options.format ?? "table", + runId: options.run, + workspaceRoot: resolveWorkspaceRoot(wsRoot), + }, + logger, + ); +}; diff --git a/packages/tooling/vis/src/commands/run/apply-service-registry.ts b/packages/tooling/vis/src/commands/run/apply-service-registry.ts index 789b5308..b7cd9586 100644 --- a/packages/tooling/vis/src/commands/run/apply-service-registry.ts +++ b/packages/tooling/vis/src/commands/run/apply-service-registry.ts @@ -2,13 +2,7 @@ import type { Task, TaskGraph } from "@visulima/task-runner"; import { isAlive } from "../../services/registry"; import type { ServiceEntry } from "../../services/types"; -import type { VisTargetOptions } from "../../task/target-options"; - -const getVisOptions = (task: Task): VisTargetOptions | undefined => { - const options = task.overrides["visOptions"]; - - return options && typeof options === "object" ? options : undefined; -}; +import { getVisOptions } from "../../task/target-options"; export interface ServiceRegistryDiagnostic { /** Always actionable — explains how to satisfy the dep. */ diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-context.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-context.ts new file mode 100644 index 00000000..e1c8503d --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-context.ts @@ -0,0 +1,103 @@ +import type { Toolbox } from "@visulima/cerebro"; +import type { Task, TaskGraph } from "@visulima/task-runner"; + +import type { buildProjectGraph, discoverWorkspace } from "../../../config/workspace"; +import type { VisTargetConfiguration } from "../../../task/target-options"; +import { detectCurrentOs } from "../../../task/target-options"; +import type { ServiceDockStore } from "../../../tui/components/service-dock/service-dock-store"; +import type { RunOptions } from "../index"; +import type { ServiceEventBridge } from "../service-event-bridge"; + +export type RunToolbox = Toolbox; + +type Discovered = ReturnType; + +/** + * State shared by the phases of one `vis run`. Every local that a later phase + * reads or reassigns lives here; nothing is copied into a phase's parameters. + * Fields a phase sets are initialised to the value the original `let` had + * before that point, so reading one early is harmless. + */ +export interface RunContext { + /** Changed files from `--affected`, or undefined when not affected-scoped. */ + affectedChangedFiles: string[] | undefined; + affectedFiles: string[] | undefined; + /** Positional arguments: the target selector, then forwarded task args. */ + argument: RunToolbox["argument"]; + config: Discovered["config"]; + currentOs: ReturnType; + /** Services booted by this run (ephemeral pid files, scratch dir, registry starts). */ + ephemeralPidFiles: string[]; + fileOwners: Discovered["fileOwners"]; + forwardedArgs: string[]; + initialTasks: Task[]; + injectedServiceIds: string[]; + injectedServiceIdSet: Set; + invocationCwd: string; + + logger: RunToolbox["logger"]; + options: RunOptions; + packageJsons: Discovered["packageJsons"]; + persistentTasks: Task[]; + projectGraph: ReturnType; + projectNames: string[]; + projectOptions: Discovered["projectOptions"]; + projectsWithTarget: string[]; + projectTargetIndex: Map; + registryStartedCount: number; + registryStartedIds: string[]; + + registryStartedPids: Map; + serviceDockStore: ServiceDockStore | null; + serviceEventBridge: ServiceEventBridge | null; + serviceRunDir: string | undefined; + /** Target name after alias resolution. */ + target: string; + taskGraph: TaskGraph; + visConfig: RunToolbox["visConfig"]; + workspace: Discovered["workspace"]; + workspaceRoot: string; +} + +/** + * Builds the context from the command inputs. Phase-owned fields start at the + * same values the original locals had before their first assignment. + */ +export const createRunContext = (inputs: { + argument: RunToolbox["argument"]; + config: Discovered["config"]; + fileOwners: Discovered["fileOwners"]; + invocationCwd: string; + logger: RunToolbox["logger"]; + options: RunOptions; + packageJsons: Discovered["packageJsons"]; + projectGraph: RunContext["projectGraph"]; + projectOptions: Discovered["projectOptions"]; + visConfig: RunToolbox["visConfig"]; + workspace: Discovered["workspace"]; + workspaceRoot: string; +}): RunContext => { + return { + ...inputs, + affectedChangedFiles: undefined, + affectedFiles: undefined, + currentOs: detectCurrentOs(), + ephemeralPidFiles: [], + forwardedArgs: [], + initialTasks: [], + injectedServiceIds: [], + injectedServiceIdSet: new Set(), + persistentTasks: [], + projectNames: [], + projectsWithTarget: [], + projectTargetIndex: new Map(), + registryStartedCount: 0, + registryStartedIds: [], + registryStartedPids: new Map(), + serviceDockStore: null, + serviceEventBridge: null, + serviceRunDir: undefined, + target: "", + taskGraph: { dependencies: {}, roots: [], tasks: {} }, + }; +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/executor.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-executor.ts similarity index 99% rename from packages/tooling/vis/src/commands/run/handler-parts/executor.ts rename to packages/tooling/vis/src/commands/run/handler-parts/run-executor.ts index 4f5fdbc2..29d7de9f 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/executor.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-executor.ts @@ -9,7 +9,7 @@ import { scheduleTimeoutKill } from "../../../util/signal-escalation"; import type { ServiceEventBridge } from "../service-event-bridge"; import type { TaskSpawnContext } from "../task-env"; import { getTaskOptions, resolveTaskSpawn } from "../task-env"; -import { MAX_OUTPUT_BYTES, OutputRingBuffer } from "./output"; +import { MAX_OUTPUT_BYTES, OutputRingBuffer } from "./run-output"; /** * Serializes tasks that share a mutex name. Keyed by mutex name, each diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-graph.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-graph.ts new file mode 100644 index 00000000..9a610117 --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-graph.ts @@ -0,0 +1,313 @@ +import type { TargetConfiguration } from "@visulima/task-runner"; +import { createTaskGraph, expandTokensInString, reverseTaskGraph, splitCompoundTasks } from "@visulima/task-runner"; +import isInCi from "is-in-ci"; + +import { runLockfilePreflight } from "../../../preflight/lockfile"; +import { maybePromptViteClientOverride } from "../../../preflight/vite-client-override"; +import { runToolchainPreflight } from "../../../runtime/toolchain"; +import { resolveSkipCachePatterns } from "../../../task/skip-cache"; +import { resolveTargetShell } from "../../../task/target-options"; +import type { RunContext } from "./run-context"; + +/** + * Phase 4: build the task graph from the selected tasks, hydrate dependency + * tasks with their vis options and command, and apply `--skip-cache`. + */ +export const buildRunGraph = (ctx: RunContext): void => { + const { config, logger, options, projectGraph, projectOptions, workspace } = ctx; + + // User-requested task ids (post-partition), so the dependency-hydration + // pass below can tell a `dependsOn`-added task apart from one the user + // asked for directly and only warn about missing commands on the former. + const requestedTaskIds = new Set([...ctx.initialTasks, ...ctx.persistentTasks].map((task) => task.id)); + + // Include persistent tasks in the graph so their `dependsOn` chain + // (especially service deps like `web:serve → api:db`) is walked by + // `applyServiceRegistry`. Without this, persistent tasks are split + // out before graph construction and their service deps go undiscovered, + // so preflight never auto-starts them. The persistent task ids are + // stripped from the graph again after preflight injection so the + // regular runner doesn't execute them — they run via `runPersistentTasks`. + ctx.taskGraph = createTaskGraph([...ctx.initialTasks, ...ctx.persistentTasks], { + onCycleBroken: (cycle) => { + // A dependency cycle that runs only through devDependency edges + // is tolerated (pnpm does the same) — we break it and warn rather + // than deadlocking. Cycles with a real (static) edge stay fatal. + logger.warn(`Ignoring dev-only dependency cycle (build order is ambiguous): ${cycle.join(" → ")}`); + }, + onUnmatchedProjectSelector: (selector) => { + // An unmatched `dependsOn.projects` entry produces no edge, so + // the declared ordering just isn't there. Silence made a typo + // and a working config indistinguishable. + logger.warn(`dependsOn.projects entry "${selector}" matched no project — that dependency edge was not created.`); + }, + projectGraph, + targetDefaults: config.tasks as unknown as Record>, + workspace, + }); + + // task-runner's createTaskGraph copies overrides only from the + // user-invoked `initialTasks`, so dep tasks added via `dependsOn` + // arrive without `visOptions` and without `command`. That hides + // every vis-specific option on dep tasks — most importantly + // `options.service`, which applyServiceRegistry needs to detect a + // service dependency it should auto-attach to — and leaves the + // service-preflight injector with no command to wrap. Re-hydrate + // both fields for every task in the graph from `projectOptions` + // so dep tasks carry the same target metadata that user-invoked + // tasks already carry. + for (const [taskId, task] of Object.entries(ctx.taskGraph.tasks)) { + const projectName = task.target.project; + const targetName = task.target.target; + const visTarget = projectOptions.get(projectName)?.[targetName]; + + if (!visTarget) { + // A `dependsOn`-added dependency that resolves to a target with + // no configuration anywhere (e.g. `dependsOn: ["codegen"]` but + // the project has no `codegen` script/target). task-runner keeps + // it in the graph and the executor runs it as a silent 0-exit + // no-op, so the missing prerequisite vanishes instead of erroring + // — the trap that makes such gaps CI-only and hard to find. Warn + // so the misconfiguration is visible. Tasks the user invoked + // directly always carry config, so this only fires for deps. + if (!requestedTaskIds.has(taskId)) { + logger.warn(`${targetName} required via dependsOn but no command is configured for ${projectName} — skipping (no-op).`); + } + + continue; + } + + const project = workspace.projects[projectName]; + let mutated = false; + const nextOverrides = { ...task.overrides }; + + if (nextOverrides["visOptions"] === undefined && visTarget.options) { + nextOverrides["visOptions"] = visTarget.options; + mutated = true; + } + + if (nextOverrides["command"] === undefined && visTarget.command) { + nextOverrides["command"] = expandTokensInString(visTarget.command, { affectedFiles: ctx.affectedFiles, projectRoot: project?.root }); + mutated = true; + } + + if (mutated) { + task.overrides = nextOverrides; + ctx.taskGraph.tasks[taskId] = task; + } + } + + // Per-task cache bypass via --skip-cache=. Unlike the + // global --no-cache (which drops the runner's `skipNxCache` switch + // and bypasses cache for every task), this only flips + // `task.cache = false` on tasks whose ID matches a user-supplied + // pattern. The dry-run plan printer already surfaces this as + // `(no-cache)` next to the matched task IDs. We skip the work + // entirely when --no-cache is set since the runner-level switch + // already disables caching for the whole run. + if (typeof options.skipCache === "string" && options.skipCache.trim() !== "") { + if (options.cache) { + const skipResolution = resolveSkipCachePatterns(options.skipCache, workspace, Object.keys(ctx.taskGraph.tasks)); + + for (const id of skipResolution.skipTaskIds) { + const task = ctx.taskGraph.tasks[id]; + + if (task !== undefined) { + task.cache = false; + } + } + + if (skipResolution.unmatchedPatterns.length > 0) { + logger.warn(`--skip-cache: no tasks matched ${skipResolution.unmatchedPatterns.map((p) => `"${p}"`).join(", ")}`); + } + + if (skipResolution.skipTaskIds.size > 0) { + logger.debug?.(`--skip-cache: bypassing cache for ${String(skipResolution.skipTaskIds.size)} task(s)`); + } + } else { + logger.debug?.("--skip-cache ignored: --no-cache already disables caching for the whole run"); + } + } +}; + +/** + * Phase 5: toolchain pin check, lockfile drift check, and the one-time Vite + * client override prompt. Runs before service auto-attach and task execution. + */ +export const runPreflightChecks = async (ctx: RunContext): Promise => { + const { config, logger, options, packageJsons, workspaceRoot } = ctx; + + // Pre-flight: if a workspace tool pin doesn't match the running + // version and `toolchain.autoInstall` is on (default when a + // manager is detected), install via the right manager and let + // subsequent task subprocesses pick up the new version. We + // never block on failure — surface a warning, keep going, + // and let the existing runtime-check warnings do their job. + // + // Runs before service auto-attach so the right Node/PM is in + // place when the probe runs, and before the dry-run short-circuit + // so `--dry-run` still surfaces toolchain drift. + await runToolchainPreflight( + workspaceRoot, + config.toolchain, + { + error: (message) => { + logger.error(message); + }, + info: (message) => { + logger.info(message); + }, + warn: (message) => { + logger.warn(message); + }, + }, + Boolean(options.skipToolchain), + ); + + // Lockfile/install drift check. Fires after toolchain (so the + // suggested install command runs against the right Node) and + // before service auto-attach + task execution. CLI flag wins over + // config; both default to on. The helper logs the warn line itself + // in TTY; in CI it stays silent and we throw with `formattedMessage` + // so the user sees the detail exactly once. + const preflightEnabled = options.preflight !== false && config.preflight?.lockfile !== false; + const lockfilePreflight = runLockfilePreflight( + workspaceRoot, + isInCi, + { + warn: (message) => { + logger.warn(message); + }, + }, + { skip: !preflightEnabled }, + ); + + if (!lockfilePreflight.shouldContinue) { + throw new Error(`${lockfilePreflight.formattedMessage ?? "preflight: lockfile drift detected"} (pass --no-preflight to bypass)`); + } + + // One-time offer to alias @voidzero-dev/vite-task-client to our + // drop-in client so its cache hints reach the runner. TTY-only and + // remembers a decline, so CI and repeat runs are never blocked. + if (preflightEnabled && !isInCi && process.stdin.isTTY && process.stdout.isTTY) { + await maybePromptViteClientOverride(workspaceRoot, { + interactive: true, + logger: { + info: (message) => { + logger.info(message); + }, + warn: (message) => { + logger.warn(message); + }, + }, + // Scan every discovered project manifest, not just the root — + // a tool depending on the vite client may live in a sub-package. + projectManifests: [...packageJsons.values()], + }); + } +}; + +/** + * Phase 7a: flip the graph for teardown targets, mark splitCompound tasks, + * and expand them into their segment chain. + */ +export const shapeTaskGraph = (ctx: RunContext): void => { + const { logger, options, projectOptions } = ctx; + + // Flip the task graph for teardown targets (CDK/Pulumi `destroy`, + // `undeploy`, etc.) where dependents must run before the things + // they depend on. Runs after service auto-attach so service + // detection still uses the natural graph; runs before the + // dry-run walk and scheduler so both reflect the reversed order. + // `reverseTaskGraph` recomputes `roots` from the flipped edges, + // so the dry-run printer and `TaskScheduler` need no further + // changes — they already walk topologically over `dependencies`. + if (options.reverse) { + ctx.taskGraph = reverseTaskGraph(ctx.taskGraph); + logger.debug?.(`Reversed task graph: ${String(ctx.taskGraph.roots.length)} new root(s) (originally leaves)`); + } + + // `splitCompound` splits a POSIX shell line, so a target with a custom + // shell runs its command in another language and stays whole. + for (const task of Object.values(ctx.taskGraph.tasks)) { + const shellOptions = projectOptions.get(task.target.project)?.[task.target.target]?.options; + + if (task.splitCompound && resolveTargetShell(shellOptions) !== undefined) { + logger.warn(`splitCompound ignored for ${task.id}: it uses a custom shell. Running the command as one unit.`); + task.splitCompound = undefined; + } + } + + // Split `splitCompound` targets into chained segment tasks before the + // dry-run plan and the scheduler read the graph. The runner's own split + // pass then finds nothing left to do. + ctx.taskGraph = splitCompoundTasks(ctx.taskGraph, { + commandOf: (task) => task.overrides["command"] as string | undefined, + onDiagnostic: (_taskId, message) => { + logger.warn(message); + }, + }); +}; + +/** + * Phase 7b: `--dry-run` prints the execution plan and stops. Returns `true` + * when the run must stop here. + */ +export const printDryRunPlan = (ctx: RunContext): boolean => { + const { config, logger, options } = ctx; + + if (options.dryRun) { + const taskCount = Object.keys(ctx.taskGraph.tasks).length; + const rootCount = ctx.taskGraph.roots.length; + + logger.info(`Execution plan (${String(taskCount)} task(s), ${String(rootCount)} root(s)):`); + logger.info(""); + + const visited = new Set(); + const walkPlan = (id: string, depth: number): void => { + if (visited.has(id)) { + return; + } + + visited.add(id); + + for (const dep of ctx.taskGraph.dependencies[id] ?? []) { + walkPlan(dep, depth + 1); + } + + const task = ctx.taskGraph.tasks[id]; + const indent = " ".repeat(depth + 1); + const segmentNote = task?.segment ? ` (segment ${String(task.segment.index)}/${String(task.segment.count)}: ${String(task.overrides["command"])})` : ""; + // The engine traces every task under the workspace switch, so it is annotated like a per-target trace. + const traced = task?.hashMode === "trace" || config.taskRunner?.autoFingerprint === true; + const annotations = `${task?.cache === false ? " (no-cache)" : ""}${traced ? " (trace)" : ""}${segmentNote}`; + + logger.info(`${indent}${id}${annotations}`); + }; + + for (const root of ctx.taskGraph.roots) { + walkPlan(root, 0); + } + + // Visit any tasks not reachable from `roots`. Two cases hit + // this: disconnected graph components, and reversed graphs + // where reverse-roots don't reach the original-leaves through + // outbound `dependencies` (the printer recurses into deps, + // and original-leaves are reverse-dependents — so we'd + // otherwise drop them silently from the plan). + for (const taskId of Object.keys(ctx.taskGraph.tasks)) { + walkPlan(taskId, 0); + } + + if (ctx.persistentTasks.length > 0) { + logger.info(""); + logger.info(` + ${String(ctx.persistentTasks.length)} persistent task(s) (run after graph completes)`); + } + + logger.info(""); + + return true; + } + + return false; +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts new file mode 100644 index 00000000..14f96204 --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts @@ -0,0 +1,629 @@ +import type { LogMode, ProcessEvent, TaskResults } from "@visulima/task-runner"; +import { CompositeLifeCycle, createLogReporter, defaultTaskRunner, generateRunSummary, runConcurrently, TerminalBuffer, writeRunSummary } from "@visulima/task-runner"; + +import { VisUserError } from "../../../errors/vis-user-error"; +import { analyzeFlakiness, formatFlakinessTable } from "../../../report/flakiness"; +import { compareDuration, formatTimingSummary, loadRunSummaries } from "../../../report/run-report"; +import { createDynamicOutputRenderer } from "../../../tui/dynamic-life-cycle"; +import { StaticOutputLifeCycle } from "../../../tui/static-life-cycle"; +import type { StdinEntry } from "../../../tui/types"; +import { getVisWorkspaceDataDir } from "../../../util/vis-paths"; +import { collectTrackedWatchTargets, createTrackedFileFilter, startWatcher } from "../../../watch/watch"; +import { applyProjectFilter } from "../../../watch/watch-filter"; +import type { KeybindHandle } from "../../../watch/watch-keybinds"; +import { installKeybinds, writeHelp } from "../../../watch/watch-keybinds"; +import { resolveRetrySpawn, resolveTaskSpawn } from "../task-env"; +import type { RunContext } from "./run-context"; +import type { MutexPool } from "./run-executor"; +import { createConcurrentExecutor } from "./run-executor"; +import { MAX_OUTPUT_BYTES } from "./run-output"; +import { runPersistentTasks } from "./run-persistent-tasks"; +import type { RunRuntime } from "./run-runtime"; +import { resolveTaskCommand } from "./run-shell"; + +/** + * Interactive (TTY) run: the dynamic renderer drives reruns, retries and quit + * through the store, so the whole loop stays in one function. + */ +export const runInteractiveMode = async (ctx: RunContext, runtime: RunRuntime): Promise => { + const { currentOs, logger, options, projectGraph, workspaceRoot } = ctx; + const { childEnvPolicyFor, failureLogLifeCycle, hookLifeCycle, hooks, lifecycleOptions, maybeWriteProfile, outputStyle, preTraceCheck, resolveCommand, runnerOptions, sharedRetryBudget, spawnContext } = runtime; + // Snapshots of service state that is final before this phase; const so the callbacks below narrow. + const { serviceEventBridge } = ctx; + const { serviceDockStore } = ctx; + + const stdinRegistry = new Map(); + const dynamic = createDynamicOutputRenderer({ + ...lifecycleOptions, + onRetryService: serviceEventBridge ? (id) => serviceEventBridge.retry(id) : undefined, + outputStyle, + serviceDockStore, + stdinRegistry, + }); + const { lifeCycle: uiLifeCycle, store } = dynamic; + // Fan lifecycle events out to both the UI renderer and the + // plugin hook layer so subscribers see the same events + // without adding another renderer. + const lifeCycle = new CompositeLifeCycle([uiLifeCycle, hookLifeCycle, failureLogLifeCycle]); + const mutexPool: MutexPool = new Map(); + const taskExecutor = createConcurrentExecutor({ + hooks, + lifeCycle, + mutexPool, + onOutput: (taskId, text) => { + if (serviceEventBridge && ctx.injectedServiceIdSet.has(taskId)) { + serviceEventBridge.onTaskOutput(taskId, text); + } else { + store.addOutput(taskId, text); + } + }, + onOutputReplace: (taskId, fullContent) => { + store.setOutput(taskId, fullContent); + }, + resolveCommand, + retryBudget: sharedRetryBudget, + serviceEventBridge: serviceEventBridge ?? undefined, + spawnContext, + stdinRegistry, + strictEnvFailure: runtime.strictEnvFailure, + workspaceRoot, + }); + + let loopAction: "quit" | "rerun" | "retry" = "rerun"; + let retryTaskId: string | null = null; + + let lastResults: TaskResults = new Map(); + + while (loopAction !== "quit") { + if (loopAction === "rerun") { + lastResults = await defaultTaskRunner(ctx.initialTasks, runnerOptions, { + childEnvPolicy: childEnvPolicyFor, + lifeCycle, + preTraceCheck, + projectGraph, + resolveChildEnv: (task, spawnOptions) => resolveTaskSpawn(spawnContext, task, spawnOptions), + resolveCommand, + taskExecutor, + taskGraph: ctx.taskGraph, + workspaceRoot, + }); + + // Persistent tasks (servers, watchers) start AFTER the + // regular graph completes but BEFORE the loop awaits + // user input — that's the only way the dynamic TUI + // can show their spinners and stream their output. + // The abortSignal kills them when the user signals + // rerun/retry/quit so the outer loop can react. + if (ctx.persistentTasks.length > 0 && !options.failFast) { + const abortSignal = new Promise((resolve) => { + const unsubscribe = store.subscribe(() => { + const s = store.getSnapshot(); + + if (s.rerunRequested || s.retryTaskId) { + unsubscribe(); + resolve(); + } + }); + + dynamic.renderIsDone + .then(() => { + unsubscribe(); + resolve(); + + return undefined; + }) + .catch(() => { + unsubscribe(); + resolve(); + }); + }); + + await runPersistentTasks(ctx.persistentTasks, spawnContext, { + abortSignal, + lifeCycle, + stdinRegistry, + store, + }); + } + } else if (loopAction === "retry" && retryTaskId) { + // A split target retries by segment id, which only the graph knows. + const task = ctx.taskGraph.tasks[retryTaskId] ?? ctx.initialTasks.find((t) => t.id === retryTaskId); + // Same command the first attempt ran, so a retry keeps its forwarded arguments and shell. + const command = task ? resolveTaskCommand(task, spawnContext.affectedFiles, currentOs) : undefined; + + if (task && command) { + lifeCycle.startTasks?.([task]); + + const retryTermBuf = new TerminalBuffer(MAX_OUTPUT_BYTES); + + // Same cwd and child environment as the first attempt. + const retrySpawn = resolveRetrySpawn(spawnContext, task); + + const retryResult = await runConcurrently( + [ + { + command, + cwd: retrySpawn.cwd, + env: retrySpawn.env, + inheritEnv: retrySpawn.inheritEnv, + name: task.id, + ptySize: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 }, + stdin: "pty", + }, + ], + { + onEvent: (event: ProcessEvent) => { + if (event.kind === "started" && event.write) { + stdinRegistry.set(task.id, { kill: event.kill, resize: event.resize, write: event.write }); + } + + if ((event.kind === "stdout" || event.kind === "stderr") && event.text) { + retryTermBuf.write(event.text); + store.setOutput(task.id, retryTermBuf.toString()); + } + + if (event.kind === "close") { + stdinRegistry.delete(task.id); + } + }, + }, + ); + + const closeEvent = retryResult.closeEvents[0]; + + lifeCycle.endTasks?.([ + { + code: closeEvent?.exitCode ?? 1, + status: closeEvent?.exitCode === 0 ? "success" : "failure", + task, + terminalOutput: store.getSnapshot().outputs.get(task.id), + }, + ]); + } else if (task) { + lifeCycle.endTasks?.([ + { + code: 1, + status: "failure", + task, + terminalOutput: `No command configured for ${task.id}`, + }, + ]); + } + + retryTaskId = null; + + // Mark done after retry so user can rerun/retry again + store.markDone(); + } + + loopAction = await new Promise<"quit" | "rerun" | "retry">((resolve) => { + const unsubscribe = store.subscribe(() => { + const s = store.getSnapshot(); + + if (s.rerunRequested) { + store.acknowledgeRerun(); + unsubscribe(); + resolve("rerun"); + } + + if (s.retryTaskId) { + retryTaskId = store.acknowledgeRetry(); + unsubscribe(); + resolve("retry"); + } + }); + + dynamic.renderIsDone + .then(() => { + unsubscribe(); + resolve("quit"); + + return undefined; + }) + .catch(() => { + unsubscribe(); + resolve("quit"); + }); + }); + } + + await dynamic.renderIsDone; + await runtime.hooks.callHook("run:after", lastResults); + await maybeWriteProfile(lastResults); + + if (ctx.registryStartedCount > 0) { + logger.info(""); + + if (options.stopServices) { + logger.info(`${String(ctx.registryStartedCount)} service(s) stopped (--stop-services).`); + } else { + logger.info(`${String(ctx.registryStartedCount)} service(s) started in the background. Run \`vis service stop --all\` to clean up.`); + } + } +}; + +type RunOnceResult = { hasFailure: boolean; results: TaskResults; runHistory: Awaited> }; + +/** + * Non-interactive (CI / piped) run: one pass, then the failure report. With + * `--watch` it hands over to the file watcher instead. + */ +export const runStaticMode = async (ctx: RunContext, runtime: RunRuntime): Promise => { + const { logger, options, visConfig, workspaceRoot } = ctx; + const { childEnvPolicyFor, failureLogLifeCycle, hookLifeCycle, hooks, lifecycleOptions, maybeWriteProfile, outputStyle, preTraceCheck, resolveCommand, runnerOptions, sharedRetryBudget, spawnContext, startTime } = runtime; + + const mutexPool: MutexPool = new Map(); + const logModeOption = typeof options.log === "string" ? options.log.toLowerCase() : ""; + const logMode: LogMode | undefined + = logModeOption === "labeled" || logModeOption === "grouped" || logModeOption === "interleaved" ? logModeOption : undefined; + const logReporter = logMode ? createLogReporter(logMode) : undefined; + // Composite so plugin hooks see every task boundary event in + // addition to the CI-style static renderer. Build the + // lifecycle first so the executor can forward streaming + // stdout/stderr chunks into it. + const lifeCycle = new CompositeLifeCycle([ + new StaticOutputLifeCycle({ ...lifecycleOptions, ciGrouping: visConfig?.run?.ciGrouping ?? "auto", logReporter, outputStyle }), + hookLifeCycle, + failureLogLifeCycle, + ]); + + const taskExecutor = createConcurrentExecutor({ + hooks, + lifeCycle, + mutexPool, + resolveCommand, + retryBudget: sharedRetryBudget, + serviceEventBridge: ctx.serviceEventBridge ?? undefined, + spawnContext, + strictEnvFailure: runtime.strictEnvFailure, + workspaceRoot, + }); + + const runOnce = async (): Promise => { + const runStart = Date.now(); + + const results = await defaultTaskRunner(ctx.initialTasks, runnerOptions, { + childEnvPolicy: childEnvPolicyFor, + lifeCycle, + preTraceCheck, + projectGraph: ctx.projectGraph, + resolveChildEnv: (task, spawnOptions) => resolveTaskSpawn(spawnContext, task, spawnOptions), + resolveCommand, + taskExecutor, + taskGraph: ctx.taskGraph, + workspaceRoot, + }); + + const durationMs = Date.now() - runStart; + + if (options.summarize) { + const summary = generateRunSummary(results, ctx.taskGraph, startTime); + + await writeRunSummary(summary, workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) }); + } + + let hasFailure = false; + + for (const [, result] of results) { + if (result.status === "failure") { + hasFailure = true; + } + } + + const timingLine = formatTimingSummary(results, durationMs); + + // Load historical summaries once here and share with + // the flakiness analyzer below — both consumers need + // the same files and duplicate reads dominate when + // the runs/ dir has accumulated hundreds of entries. + const runHistory = loadRunSummaries(workspaceRoot); + const durationComparison = compareDuration(workspaceRoot, durationMs, runHistory); + + logger.info(""); + logger.info(` ${timingLine}${durationComparison ? ` ${durationComparison}` : ""}`); + + return { hasFailure, results, runHistory }; + }; + + const firstRun = await runOnce(); + + await hooks.callHook("run:after", firstRun.results); + await maybeWriteProfile(firstRun.results); + + const { hasFailure } = firstRun; + + if (options.watch) { + await runWatchMode(ctx, runOnce, firstRun.results); + + return; + } + + // Print historical flakiness regardless of this run's outcome — + // a clean run that masked a flaky task via retries (or one that + // hasn't tripped the flake yet today) shouldn't hide history + // that would help the user investigate. Suppressed with + // `--no-flaky`. Reuses the history already loaded for the + // timing comparison above — the runs/ directory hasn't + // changed since this turn of the loop started. + if (options.flaky) { + const flakyStats = analyzeFlakiness(workspaceRoot, { minRuns: 2 }, firstRun.runHistory); + + if (flakyStats.length > 0) { + logger.info(""); + logger.info("Flaky tasks (based on historical runs):"); + logger.info(""); + + for (const line of formatFlakinessTable(flakyStats)) { + logger.info(` ${line}`); + } + + logger.info(""); + } + } + + // `--fail-on-retry` upgrades any retried-but-passed task into a + // run failure. Lets CI surface flakes that retries would + // otherwise mask — useful as a periodic "nightly" check that + // disables the safety net the day-to-day workflow keeps on. + const retriedTaskIds: string[] = []; + + for (const [taskId, result] of firstRun.results) { + if (result.retryAttempts && result.retryAttempts > 0) { + retriedTaskIds.push(taskId); + } + } + + const failOnRetry = options.failOnRetry && retriedTaskIds.length > 0; + + if (failOnRetry) { + // Cap the rendered list so a wide flake (every task retried + // once) doesn't produce a multi-screen warn line that buries + // surrounding context in CI logs. + const MAX_LISTED = 5; + const listedIds = retriedTaskIds.slice(0, MAX_LISTED); + const remaining = retriedTaskIds.length - listedIds.length; + const idsRendered = remaining > 0 ? `${listedIds.join(", ")}, and ${String(remaining)} more` : listedIds.join(", "); + + logger.warn(""); + logger.warn(`--fail-on-retry: ${String(retriedTaskIds.length)} task(s) succeeded only after retry: ${idsRendered}`); + } + + if (hasFailure || failOnRetry) { + const headline = failOnRetry && !hasFailure ? "Some tasks succeeded only after retry (--fail-on-retry)." : "Some tasks failed."; + + // Tail the captured terminal output of every failed task into + // the thrown error so callers (especially programmatic ones + // like vitest runs that mute the logger) see *why* a task + // failed, not just *that* one did. Cap each tail so a runaway + // log doesn't drown the stack trace. + const TAIL_BYTES = 2000; + const failureDetails: string[] = []; + + for (const [taskId, result] of firstRun.results) { + if (result.status !== "failure") { + continue; + } + + const output = result.terminalOutput ?? ""; + const tail = output.length > TAIL_BYTES ? `…${output.slice(-TAIL_BYTES)}` : output; + const code = result.code ?? "?"; + + failureDetails.push( + ` ${taskId} (exit ${String(code)}):\n${tail + .split("\n") + .map((l) => ` ${l}`) + .join("\n")}`, + ); + } + + // A failed task is the run reporting a result, not vis + // malfunctioning — a stack here would bury the summary block + // that actually names the failures. + throw new VisUserError(failureDetails.length > 0 ? `${headline}\n${failureDetails.join("\n")}` : headline); + } + + if (ctx.persistentTasks.length > 0 && !options.failFast) { + await runPersistentTasks(ctx.persistentTasks, spawnContext, undefined); + } + + if (ctx.registryStartedCount > 0) { + logger.info(""); + + if (options.stopServices) { + logger.info(`${String(ctx.registryStartedCount)} service(s) stopped (--stop-services).`); + } else { + logger.info(`${String(ctx.registryStartedCount)} service(s) started in the background. Run \`vis service stop --all\` to clean up.`); + } + } +}; + +/** + * `--watch`: rerun on file change and handle the keybinds until quit. Returns + * when the user quits. + */ +const runWatchMode = async (ctx: RunContext, runOnce: () => Promise, initialResults: TaskResults): Promise => { + const { logger, workspaceRoot } = ctx; + + const absoluteRoots = ctx.projectsWithTarget + .map((name) => { + const project = ctx.workspace.projects[name]; + const root = project?.root; + + if (!root) { + return undefined; + } + + return root.startsWith("/") ? root : `${workspaceRoot}/${root}`; + }) + .filter((p): p is string => p !== undefined); + + // Snapshot the post-partition task set so `a` (clear filter) + // can return to it after `p` narrowed the run. + const baseTasks = ctx.initialTasks; + let projectFilter: string | undefined; + + const applyFilter = (filter: string | undefined): number => { + const result = applyProjectFilter(baseTasks, filter); + + projectFilter = result.filter; + ctx.initialTasks = result.tasks; + + return result.tasks.length; + }; + + let running = false; + let lastResults = initialResults; + + // Builds a fresh watcher after each run so the watch set + // reflects the files the latest run actually touched. + // Falls back to project roots on the first invocation + // (no prior results) or when no task carries hashDetails. + const buildHandle = (): { handle: ReturnType; mode: "tracked" | "roots" } => { + const targets = collectTrackedWatchTargets(lastResults, workspaceRoot); + + if (targets.directories.length > 0 && targets.files.size > 0) { + const filter = createTrackedFileFilter(targets.files, workspaceRoot, targets.directories); + + return { + handle: startWatcher({ + filter, + onChange: onChangeHandler, + paths: targets.directories, + }), + mode: "tracked", + }; + } + + return { + handle: startWatcher({ onChange: onChangeHandler, paths: absoluteRoots }), + mode: "roots", + }; + }; + + let currentHandle: ReturnType | undefined; + + const onChangeHandler = async (paths: string[]): Promise => { + if (running) { + return; + } + + running = true; + + try { + logger.info(`Change detected in ${paths.length} file(s), rerunning…`); + + const nextRun = await runOnce(); + + lastResults = nextRun.results; + + // Rebuild the watcher so the next event only + // fires for files the newest run still cares + // about. Cheap — under 100ms on typical graphs. + currentHandle?.close(); + + const rebuilt = buildHandle(); + + currentHandle = rebuilt.handle; + } finally { + running = false; + } + }; + + const initial = buildHandle(); + + currentHandle = initial.handle; + + const scope + = initial.mode === "tracked" + ? `Watching ${String(collectTrackedWatchTargets(lastResults, workspaceRoot).files.size)} tracked file(s)` + : `Watching ${String(absoluteRoots.length)} project root(s)`; + + logger.info(`${scope} — edit a file to rerun, press h for keybinds, q to quit.`); + + const triggerRerun = async (): Promise => { + if (running) { + return; + } + + running = true; + + try { + if (ctx.initialTasks.length === 0) { + logger.info("No tasks match the active filter — press a to clear it."); + + return; + } + + const nextRun = await runOnce(); + + lastResults = nextRun.results; + currentHandle?.close(); + + const rebuilt = buildHandle(); + + currentHandle = rebuilt.handle; + } finally { + running = false; + } + }; + + await new Promise((resolve) => { + let exited = false; + // Forward declarations so `exit()` can reference both the + // SIGINT listener and the keybind handle without hitting + // a TDZ if a future refactor calls exit() synchronously. + let keybinds: KeybindHandle | undefined; + const onSigint = (): void => { + exit(); + }; + + const exit = (): void => { + if (exited) { + return; + } + + exited = true; + keybinds?.close(); + process.off("SIGINT", onSigint); + currentHandle?.close(); + resolve(); + }; + + process.on("SIGINT", onSigint); + + keybinds = installKeybinds({ + handlers: { + onClearFilter: async () => { + const total = applyFilter(undefined); + + logger.info(`Filter cleared — running ${String(total)} task(s).`); + await triggerRerun(); + }, + onFilter: async (pattern) => { + // Capture before mutating — applyFilter() overwrites + // projectFilter, so we'd lose the rollback target + // otherwise. + const previousFilter = projectFilter; + const matching = applyFilter(pattern); + + if (matching === 0) { + logger.info(`Filter "${pattern}" matched no projects — keeping previous filter.`); + applyFilter(previousFilter); + + return; + } + + logger.info(`Filter "${pattern}" matched ${String(matching)} task(s).`); + await triggerRerun(); + }, + onHelp: () => { + writeHelp(process.stdout); + }, + onQuit: () => { + exit(); + }, + onRerun: triggerRerun, + }, + }); + }); +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/output.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-output.ts similarity index 100% rename from packages/tooling/vis/src/commands/run/handler-parts/output.ts rename to packages/tooling/vis/src/commands/run/handler-parts/run-output.ts diff --git a/packages/tooling/vis/src/commands/run/handler-parts/parse.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-parse.ts similarity index 100% rename from packages/tooling/vis/src/commands/run/handler-parts/parse.ts rename to packages/tooling/vis/src/commands/run/handler-parts/run-parse.ts diff --git a/packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-persistent-tasks.ts similarity index 97% rename from packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts rename to packages/tooling/vis/src/commands/run/handler-parts/run-persistent-tasks.ts index 7f310ef6..d9d69aae 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-persistent-tasks.ts @@ -7,10 +7,11 @@ import type { VisTargetOptions } from "../../../task/target-options"; import { detectCurrentOs, resolveTaskCwd } from "../../../task/target-options"; import type { TaskStore } from "../../../tui/components/task-store"; import type { StdinEntry } from "../../../tui/types"; +import { isPidAlive } from "../../../util/process-alive"; import type { TaskSpawnContext } from "../task-env"; import { resolveTaskSpawn } from "../task-env"; -import { MAX_OUTPUT_BYTES } from "./output"; -import { resolveTaskCommand } from "./shell"; +import { MAX_OUTPUT_BYTES } from "./run-output"; +import { resolveTaskCommand } from "./run-shell"; /** * Per-service readiness re-check budget for the auto-attach probe. @@ -297,19 +298,13 @@ export const runPersistentTasks = async (tasks: Task[], spawnContext: TaskSpawnC }; /** Liveness check: true if the pid (or its group) is still in the process table. */ -const isPidAlive = (pid: number): boolean => { +const isGroupAlive = (pid: number): boolean => { try { process.kill(-pid, 0); return true; } catch { - try { - process.kill(pid, 0); - - return true; - } catch { - return false; - } + return isPidAlive(pid); } }; @@ -402,7 +397,7 @@ export const cleanupEphemeralServices = (params: { extraPids?: ReadonlyArray { + const { config, invocationCwd, logger, options, visConfig, workspaceRoot } = ctx; + const { serviceEnvByTaskId } = serviceResult; + + const startTime = Date.now(); + + // One typed hook registry per run. Plugins register handlers + // via `config.plugins`; the HookableLifeCycle forwards lifecycle + // events from task-runner into the registry so plugin authors + // see `task:before`/`task:after`/`task:cacheHit` etc. without + // needing to understand the lower-level LifeCycleInterface. + const hooks = createVisHooks(); + + // Per-run handler, passed into HookableLifeCycle below. No + // module-level state: concurrent `vis` invocations in the same + // process (programmatic API, Vitest parallel suites) each get + // their own channel. + const onHookError = (hookName: string, error: unknown): void => { + const message = error instanceof Error ? error.message : String(error); + + logger.warn(`Plugin error in ${hookName}: ${message}`); + }; + + await registerPlugins(hooks, config.plugins); + + const profilePath = typeof options.profile === "string" ? options.profile : undefined; + const maybeWriteProfile = async (results: TaskResults): Promise => { + if (!profilePath) { + return; + } + + const summary = generateRunSummary(results, ctx.taskGraph, startTime); + + const resolvedProfilePath = profilePath.startsWith("/") ? profilePath : `${workspaceRoot}/${profilePath}`; + + await writeChromeTrace(summary, resolvedProfilePath); + + logger.info(`Profile written to ${profilePath}`); + }; + + // Resolve the cache directory through the shared helper so precedence + // (CLI > vis.config.ts > default) stays consistent with `vis cache` + // and relative --cache-dir values are normalized against the + // workspace root. Other fields keep their existing spread semantics + // to avoid changing override precedence for parallel/dryRun/etc. + const configTaskRunner = (config.taskRunner ?? {}) as TaskRunnerOptions & { cacheDirectory?: string }; + // Anchor the default cache path to the *main* worktree root when this + // workspace is a linked git worktree, so sibling agents share one cache + // instead of rebuilding the same hash N times. Explicit paths + // (--cache-dir, vis.config.ts, VIS_CACHE_DIRECTORY) win unchanged. + const baseCacheDirectory = resolveSharedCacheDirectory(workspaceRoot, options.cacheDir, configTaskRunner.cacheDirectory, config.sharedWorktreeCache); + // Branch-scope the cache dir when configured so main/feature + // branches stop overwriting each other's entries. + const resolvedCacheDirectory = applyBranchScope(baseCacheDirectory, workspaceRoot, config.branchScopedCache); + + // Concurrency resolution order: --parallel CLI flag > VIS_RUN_CONCURRENCY_LIMIT + // env > built-in default of 3. Matches vite-task's + // VP_RUN_CONCURRENCY_LIMIT semantics — lets CI runners set a + // global limit via env without passing the flag on every call. + // An invalid env value surfaces as a warn so misconfigurations + // aren't silently ignored. + const parallelEnvParsed = parseEnvConcurrency(process.env["VIS_RUN_CONCURRENCY_LIMIT"]); + + if (parallelEnvParsed && "invalid" in parallelEnvParsed) { + logger.warn(`VIS_RUN_CONCURRENCY_LIMIT=${parallelEnvParsed.invalid} is not a positive number; falling back to default concurrency.`); + } + + const parallelFromEnv = parallelEnvParsed && "value" in parallelEnvParsed ? parallelEnvParsed.value : undefined; + const resolvedParallel = options.parallel ?? parallelFromEnv ?? 3; + + // CLI flag wins over the workspace config; per-target opt-in/out + // is applied later in the executor against this resolved baseline. + const resolvedStrictEnv = options.strictEnv ?? config.strictEnv ?? false; + + // One warning per task per run for the "cacheable task, zero input files" + // diagnostic (see `TaskRunnerOptions.onDiagnostic`) — a config bug usually + // trips every affected task, so dedupe to keep the output readable. + const warnedEmptyInputs = new Set(); + + const runnerOptions: TaskRunnerOptions = { + dryRun: options.dryRun ?? false, + parallel: resolvedParallel, + skipNxCache: !options.cache, + summarize: options.summarize ?? false, + ...configTaskRunner, + // Applied after the config spread so the user's `--cache-dir` flag + // wins over a config value and relative paths are normalized + // against `workspaceRoot` via `resolveCacheDirectory()`. + cacheDirectory: resolvedCacheDirectory, + // Forward the vis-owned data directory so run summaries land in + // `/.vis/` rather than the task-runner default + // `/.task-runner/`. The config-supplied value + // (if any) is honoured because the spread above can carry it + // through; the explicit assignment here guarantees vis-driven + // runs always co-locate their state under `.vis/`. + dataDirectory: configTaskRunner.dataDirectory ?? getVisWorkspaceDataDir(workspaceRoot), + // `namedInputs` lives at the TOP LEVEL of the vis config, not under + // `taskRunner`, so the `...configTaskRunner` spread above does not carry + // it. Forward it explicitly: without it the task-runner's hasher gets an + // empty named-inputs map, so a task whose `inputs` reference a named + // input (`production`, `default`, …) resolves those names as LITERAL, + // non-existent directories — contributing zero files to the cache key. + // The task then false-cache-hits forever and never re-runs on a source + // change. (Tasks with no `inputs` fall back to `{projectRoot}/**/*`, + // which is why `lint:types` worked while `build` did not.) + namedInputs: config.namedInputs, + // Surface the hasher's "cacheable task resolved 0 input files" diagnostic + // through the logger — the early-warning for a dropped/misconfigured input + // that would otherwise silently false-cache-hit forever. + onDiagnostic: (taskId, message) => { + if (warnedEmptyInputs.has(taskId)) { + return; + } + + warnedEmptyInputs.add(taskId); + logger.warn(message); + }, + // Bridge the typed `task:fingerprint` hook into task-runner's + // `onFingerprint` callback. Placed after the config spread so + // the plugin pipeline always runs — a config-supplied + // `onFingerprint` would silently disable every plugin's + // fingerprint contributions. + // + // Errors propagate intentionally: a buggy plugin must fail the + // task before any cache lookup runs (per the hook's documented + // contract — see `VisHooks["task:fingerprint"]`). + onFingerprint: async (task, contributor) => { + await hooks.callHook("task:fingerprint", task, contributor); + }, + }; + + // Child-environment policy per task: the workspace `taskRunner.env`, unless + // the target sets its own `options.env`. The executor, the persistent launch + // and the traced run build the child env with it, and the task hasher keys on + // the names it lets through. + const childEnvPolicyFor = (task: Task): Required => effectiveChildEnvPolicy(runnerOptions.env, getTaskOptions(task)?.env); + + // Built once per run. Every spawn of a task (executor, persistent launch and + // traced run) reads its child environment from this context. + const spawnContext = createTaskSpawnContext({ + affectedFiles: ctx.affectedFiles, + childEnvPolicy: childEnvPolicyFor, + initCwd: invocationCwd, + serviceEnvByTaskId, + taskSlots: resolvedParallel, + workspaceRoot, + }); + + // The command a spawn runs, and the strict-env failure for it. The executor + // and the traced run both call these, so the two read the same command and + // the same child environment. + const resolveCommand = (task: Task): string | undefined => resolveTaskCommand(task, ctx.affectedFiles, ctx.currentOs); + + const strictEnvFailure = (task: Task, command: string, execOptions: { cwd?: string }): string | undefined => { + // A per-target setting wins over the workspace flag, so one target can opt out (or in). + const strictEnvActive = getTaskOptions(task)?.strictEnv ?? resolvedStrictEnv; + + if (!strictEnvActive) { + return undefined; + } + + const spawn = resolveTaskSpawn(spawnContext, task, execOptions); + const childPolicy = spawnContext.childEnvPolicy(task); + const violation = checkStrictEnv({ + command, + // A variable the clean child cannot see is unset for the check, so `$VAR` fails the task instead of expanding to "". + processEnv: childPolicy.mode === "clean" ? pickChildParentEnv(process.env, childPolicy) : process.env, + taskEnv: spawn.env, + taskId: task.id, + }); + + return violation ? formatStrictEnvError(violation) : undefined; + }; + + // The traced run's pre-spawn check: the same strict-env failure the executor reports. + const preTraceCheck = (task: Task, spawnOptions: { cwd: string }): string | undefined => { + const command = resolveCommand(task); + + return command === undefined ? undefined : strictEnvFailure(task, command, spawnOptions); + }; + + // Resolve the remote-cache config in three layers (CLI > vis.config.ts + // > Turbo env-var compat). Fill missing HTTP fields from TURBO_API / + // TURBO_TOKEN / TURBO_TEAM so a workspace migrating from Turbo keeps + // working without rewriting CI secrets. Rejects malformed CLI values + // up front so a typo in `--cache-mode=eread` doesn't silently fall + // through to the runner and surface as "no cache hits". + const envCompatRemoteCache = resolveTurboEnvCompat(configTaskRunner.remoteCache); + + if (envCompatRemoteCache) { + const cliMode = parseCacheMode(options.cacheMode); + const cliBackend = parseCacheBackend(options.cacheBackend); + + runnerOptions.remoteCache = { + ...envCompatRemoteCache, + ...(cliMode ? { mode: cliMode } : {}), + ...(cliBackend ? { backend: cliBackend } : {}), + }; + + // Config can only carry serializable attestation knobs + // (`requireOnDownload`, `expectedIdentity`); the actual keyless + // sign/verify callbacks can't live in vis.config.ts. When the + // user opted in, swap the declarative stub for Sigstore-backed + // hooks here. + if (runnerOptions.remoteCache.attestation) { + const { expectedIdentity, requireOnDownload } = runnerOptions.remoteCache.attestation; + + // Verifying a keyless bundle without pinning the signer + // proves integrity, not authenticity — that is what the HMAC + // `signing` block already does. Accept any of the three + // public identity forms; refuse a half-configured setup + // loudly rather than ship a false sense of provenance. + const identityConfigured + = expectedIdentity !== undefined + && (("github" in expectedIdentity + && typeof expectedIdentity.github?.ref === "string" + && typeof expectedIdentity.github.repo === "string" + && typeof expectedIdentity.github.workflow === "string") + || ("oidcIssuer" in expectedIdentity + && typeof expectedIdentity.oidcIssuer === "string" + && (typeof (expectedIdentity as { san?: unknown }).san === "string" + || typeof (expectedIdentity as { sanRegex?: unknown }).sanRegex === "string"))); + + if (!identityConfigured) { + throw new Error( + "[vis run] remoteCache.attestation requires a pinned keyless signer via `expectedIdentity`. Use one of:\n • { github: { repo, workflow, ref } } (GitHub Actions — recommended)\n • { oidcIssuer, san } (literal identity; vis regex-escapes + anchors it)\n • { oidcIssuer, sanRegex } (advanced: raw regex, you own anchoring)\nWithout it, verification is integrity-only — use `remoteCache.signing` (HMAC) for that instead.", + ); + } + + // Only the raw-regex form carries the unanchored-match + // hazard: sigstore matches the SAN with `String.match`, so a + // substring-matched pattern lets a longer attacker SAN that + // merely *contains* the expected value pass. The `github` + // and literal `san` forms are escaped and `^…$`-anchored by + // `normalizeExpectedIdentity`, so they're safe by default. + if ("sanRegex" in expectedIdentity && (!expectedIdentity.sanRegex.startsWith("^") || !expectedIdentity.sanRegex.endsWith("$"))) { + logger.warn( + "[vis run] remoteCache.attestation.expectedIdentity.sanRegex is not anchored (^…$). sigstore matches it as a regex; an unanchored value is substring-matched and weakens the identity pin. Prefer the literal `san` form unless you need a pattern.", + ); + } + + const { installCommandFor, isSigstoreInstalled } = await import("../../../security/sigstore/loader"); + + // Surface a missing optional peer dep at startup rather than + // mid-run: warn (don't fail) so a misconfigured consumer + // still runs, just without provenance, with a copy-paste fix. + if (!isSigstoreInstalled()) { + logger.warn( + `[vis run] remoteCache.attestation is configured but the optional \`sigstore\` package is not installed. Cache uploads will be unsigned and signed entries can't be verified until you install it:\n ${installCommandFor(workspaceRoot)}`, + ); + } + + const { buildCacheAttestationHooks } = await import("../../../security/sigstore/cache-attestation"); + + runnerOptions.remoteCache.attestation = buildCacheAttestationHooks({ + expectedIdentity, + onReject: (hash, reason) => { + logger.warn(`[vis run] remote cache entry ${hash.slice(0, 12)} rejected: attestation ${reason}. Treating as a cache miss.`); + }, + onVerifyFailure: (message) => { + logger.warn(`[vis run] attestation verification failed: ${message}`); + }, + requireOnDownload, + workspaceRoot, + }); + } + } else if (options.cacheMode || options.cacheBackend) { + logger.warn("[vis run] --cache-mode and --cache-backend require a `remoteCache` block in vis.config.ts (or TURBO_API env); ignoring."); + } + + const isTTY = process.stdout.isTTY && !isInCi; + // Opt-in "quiet on success": suppress successful/cached task output and, + // in the interactive TUI, auto-close a few seconds after a clean run. + // Defaults to off; `--output-style` and `tui.autoExit` still override. + const quietOnSuccess = visConfig?.run?.quietOnSuccess === true; + // Auto-exit the interactive TUI after a clean run when quietOnSuccess is + // on (3s countdown). An explicit `tui.autoExit` wins. A failed run never + // auto-closes — VisTaskRunnerApp only arms the countdown when failed === 0. + const autoExitConfig = config.tui?.autoExit ?? (quietOnSuccess ? 3 : false); + // Include persistent tasks in the lifecycle's view so the header + // ("Running targets serve for 1 project") and summary count them + // even when the regular task graph is empty (e.g. `vis run serve` + // for a project whose `serve` target has `preset: "server"`). + const lifecycleOptions = { + args: { parallel: runnerOptions.parallel, targets: [ctx.target] }, + autoExit: autoExitConfig, + projectNames: ctx.projectsWithTarget, + // A `splitCompound` target runs as its segments, so list those in its + // place; the unsplit parent never completes and would show as skipped. + tasks: [...ctx.initialTasks, ...ctx.persistentTasks].flatMap((task) => { + const segments = Object.values(ctx.taskGraph.tasks).filter((candidate) => candidate.segment !== undefined && candidate.id.startsWith(`${task.id}#`)); + + return segments.length > 0 ? segments : [task]; + }), + // The table rows above only cover the directly-requested tasks. The + // executor also runs every `dependsOn` dependency in `taskGraph`, and + // those count toward the succeeded/failed/cached tallies. Pass the + // full graph size so the "N total" label shares that scope instead of + // under-reporting at the row count. + totalTaskCount: Object.keys(ctx.taskGraph.tasks).length + ctx.persistentTasks.length, + }; + + const retryBudgetLimit = typeof options.retryBudget === "number" ? options.retryBudget : undefined; + const sharedRetryBudget = retryBudgetLimit === undefined ? undefined : createRetryBudget(retryBudgetLimit); + + const hookLifeCycle = new HookableLifeCycle(hooks, onHookError); + const failureLogLifeCycle = new FailureLogLifeCycle(workspaceRoot); + + // Explicit `--output-style` wins; absent the flag, quiet only when + // run.quietOnSuccess is on (computed above, defaults to off). + const outputStyle = resolveOutputStyle(options.outputStyle, visConfig?.run?.quietOnSuccess); + + // Fire service:attach for each registered service we attached to. + // Computed earlier in `applyServiceRegistry`; the hook fires here + // because `hooks` only exists post-`registerPlugins`. `taskIds` is + // the set of in-graph dependents that consume the service's env — + // empty when the service was attached but no dependent kept it. + // + // service:attach is an observation hook (see VisHooks docstring), + // so a buggy plugin warns through `onHookError` rather than aborting + // the run. Matches how the standalone `vis service` commands fire + // service:start/service:stop in `commands/service/handler.ts`. + if (serviceResult.satisfiedServices.length > 0) { + for (const entry of serviceResult.satisfiedServices) { + const taskIds = serviceResult.serviceDependentsByServiceId.get(entry.id) ?? []; + + try { + await hooks.callHook("service:attach", entry, taskIds); + } catch (error) { + onHookError("service:attach", error); + } + } + } + + await hooks.callHook("run:before", { tasks: ctx.initialTasks, workspaceRoot }); + + return { + childEnvPolicyFor, + failureLogLifeCycle, + hookLifeCycle, + hooks, + isTTY, + lifecycleOptions, + maybeWriteProfile, + outputStyle, + preTraceCheck, + resolveCommand, + runnerOptions, + sharedRetryBudget, + spawnContext, + startTime, + strictEnvFailure, + }; +}; + +export type RunRuntime = Awaited>; + +/** + * SIGTERM the ephemeral service group + drop the scratch dir exactly + * once. Run from the run-block's `finally`, AND from a Ctrl+C/SIGTERM + * listener so children don't outlive the run when the dynamic TUI's + * own `process.exit(1)` short-circuits the normal exit path. + * + * Registers the signal listeners when there is cleanup work; the caller + * must pass the result to {@link teardownRun}. + */ +export const armRunCleanup = (ctx: RunContext) => { + const stopServicesOnExit = ctx.options.stopServices; + let cleanupRan = false; + const runCleanupOnce = (): void => { + if (cleanupRan) { + return; + } + + cleanupRan = true; + + if (ctx.serviceEventBridge) { + ctx.serviceEventBridge.dispose().catch(() => {}); + } + + // When --stop-services is set, also kill the registry-mode services + // this run started. Their pids are captured by the bridge `started` + // sink. Registry entries point at dead pids after this; `vis + // service list` / `pruneDead` cleans them up later. + const extraPids = stopServicesOnExit ? [...ctx.registryStartedPids.values()] : undefined; + + cleanupEphemeralServices({ extraPids, pidFiles: ctx.ephemeralPidFiles, runDir: ctx.serviceRunDir }); + }; + const onCleanupSignal = (): void => { + runCleanupOnce(); + }; + const hasCleanupWork = ctx.ephemeralPidFiles.length > 0 || ctx.serviceRunDir !== undefined || (stopServicesOnExit && ctx.registryStartedIds.length > 0); + + if (hasCleanupWork) { + process.on("SIGINT", onCleanupSignal); + process.on("SIGTERM", onCleanupSignal); + } + + return { hasCleanupWork, onCleanupSignal, runCleanupOnce, stopServicesOnExit }; +}; + +/** + * The `finally` of a run: stop services, detach the signal listeners, and + * drop the registry entries for services this run stopped. + */ +export const teardownRun = async (ctx: RunContext, cleanup: ReturnType): Promise => { + cleanup.runCleanupOnce(); + + if (cleanup.hasCleanupWork) { + process.off("SIGINT", cleanup.onCleanupSignal); + process.off("SIGTERM", cleanup.onCleanupSignal); + } + + // Clean exit path: also delete the registry entries for services + // we just stopped so `vis service list` doesn't show stale rows. + // The signal-handler path skips this — sync FS work in a SIGINT + // handler is risky, and `pruneDead` reconciles dead entries on + // demand anyway. + if (cleanup.stopServicesOnExit && ctx.registryStartedIds.length > 0) { + await Promise.all( + ctx.registryStartedIds.map(async (id) => { + try { + await deleteEntry(ctx.workspaceRoot, id); + } catch { + // Best-effort — entry may already be gone. + } + }), + ); + } +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts new file mode 100644 index 00000000..cf73cd7e --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts @@ -0,0 +1,587 @@ +import type { OutputSpec, Task, TaskTarget } from "@visulima/task-runner"; +import { enforceProjectConstraints, expandTokensInString, parsePartition, TaskScheduler } from "@visulima/task-runner"; +import isInCi from "is-in-ci"; + +import { buildProjectGraph, discoverWorkspace, loadVisTaskConfigsForWorkspace } from "../../../config/workspace"; +import { VisUserError } from "../../../errors/vis-user-error"; +import { resolveCommandRuntime } from "../../../runtime/command-runtime"; +import { selectAffectedProjects } from "../../../task/affected-selection"; +import { describeMisplacedTargetKeys, findMisplacedTargetKeys } from "../../../task/misplaced-options"; +import { filterProjectsByNames } from "../../../task/project-name-filter"; +import { filterProjectsByQuery, filterProjectsByTags, resolveSelector } from "../../../task/selectors"; +import { buildAliasMap, collectAvailableTargets, formatTargetList, promptTargetInteractively, resolveTargetAlias, suggestTargets } from "../../../task/target-discovery"; +import type { VisTargetConfiguration, VisTargetOptions } from "../../../task/target-options"; +import { matchesRunnerTags, shouldRunInCI } from "../../../task/target-options"; +import { appendToShellHistory } from "../../../util/shell-history"; +import { cleanupLegacyTaskRunnerLayout } from "../../../util/vis-paths"; +import { applyFilterStrings } from "../filter"; +import { resolveTaskArguments } from "../task-arguments"; +import { AFFECTED_FILES_ENV, getTaskOptions, TASK_ARG_ENV_KEY } from "../task-env"; +import type { RunContext, RunToolbox } from "./run-context"; +import { createRunContext } from "./run-context"; +import { parseHashMode, renderLastRunSummary } from "./run-parse"; +import { FORWARDED_ARGS_KEY } from "./run-shell"; + +/** + * Phase 0: resolve the runtime, sweep legacy state, handle `--last-details`, + * and load the workspace. Returns `undefined` when the run must stop here. + */ +export const loadRunWorkspace = async (inputs: { + argument: RunToolbox["argument"]; + logger: RunToolbox["logger"]; + options: RunContext["options"]; + visConfig: RunToolbox["visConfig"]; + workspaceRoot: string; +}): Promise => { + const { argument, logger, options, visConfig, workspaceRoot } = inputs; + + // Validate the selected runtime (--runtime / VIS_RUNTIME / config). `run` + // orchestrates tool binaries (tsc, vite, eslint, …) that are runtime-agnostic, + // so it deliberately does NOT change task execution based on the runtime — and + // must not inject it into the task env, which would rotate every task's cache + // hash. We only validate and resolve the runtime, matching the PM verbs. + // Runtime-specific *execution* lives in `vis x` (file runner) and the PM verbs, + // not in task orchestration. + resolveCommandRuntime({ options, visConfig }, workspaceRoot); + + // Vis used to write its run state under `.task-runner/` and the cache + // under `.task-runner-cache/`. After the cutover to `.vis/` those paths + // are no longer read, so silently sweep them away on the next run. + cleanupLegacyTaskRunnerLayout(workspaceRoot); + + // `--last-details` short-circuits execution: render the + // persisted last-run summary and exit. Handles the common + // "I ran tests 30s ago, show me what happened" case without + // re-running and (more importantly) without needing the + // workspace to be in a runnable state. + if (options.lastDetails) { + await renderLastRunSummary(workspaceRoot, logger); + + return undefined; + } + + // The directory the user invoked `vis` from, captured before any + // workspace resolution. Propagated as INIT_CWD to every task — + // matches pnpm/npm/yarn semantics so scripts can resolve paths + // relative to where the user actually ran the command. + const invocationCwd = process.cwd(); + const taskConfigs = await loadVisTaskConfigsForWorkspace(workspaceRoot, visConfig); + const { config, fileOwners, packageJsons, projectOptions, workspace } = discoverWorkspace(workspaceRoot, visConfig, taskConfigs); + const projectGraph = buildProjectGraph(workspaceRoot, workspace, packageJsons); + + return createRunContext({ + argument, + config, + fileOwners, + invocationCwd, + logger, + options, + packageJsons, + projectGraph, + projectOptions, + visConfig, + workspace, + workspaceRoot, + }); +}; + +/** + * Phase 1: pick the target selector and narrow the project set by selector, + * `--projects`, `--filter`, `--query`, `--tag` and `--affected`. Returns `true` + * when the run must stop here (nothing to run, or a diagnostic was printed). + */ +export const selectRunProjects = async (ctx: RunContext): Promise => { + const { config, logger, options, projectGraph, projectOptions, visConfig, workspace, workspaceRoot } = ctx; + let rawSelector = ctx.argument[0]; + + if (!rawSelector) { + const available = collectAvailableTargets(workspace); + + if (process.stdout.isTTY && process.stdin.isTTY) { + const picked = await promptTargetInteractively(available); + + if (!picked) { + logger.info("No target selected."); + + return true; + } + + rawSelector = picked; + + // The user ran `vis run` (no target) — without this, the + // resolved pick never appears in shell history, so + // up-arrow replays the picker, not the task. Best-effort, + // skipped on Windows / unrecognized shell. + await appendToShellHistory(`vis run ${picked}`); + } else { + // Non-interactive and no target: there is nothing to pick and + // nothing to run. Returning 0 here made the missing target a + // *passing* CI job that printed a target list where its task + // output should have been. + logger.info("Available targets:"); + logger.info(""); + logger.info(formatTargetList(available)); + logger.info(""); + + throw new VisUserError("Missing target. Usage: vis run "); + } + } + + if (config.constraints && !options.skipConstraints) { + const violations = enforceProjectConstraints(projectGraph, config.constraints); + + if (violations.length > 0) { + for (const v of violations) { + logger.error(`[${v.rule}] ${v.message}`); + } + + throw new Error(`${violations.length} project constraint violation(s) found. Use --skip-constraints to bypass.`); + } + } + + // The `--affected` modifiers are meaningless on their own. Reject them + // rather than accepting a flag that changes nothing — silently ignoring + // `--base` here is how a CI job ends up "passing" without running. + if (!options.affected) { + // `--no-uncommitted` arrives here as `uncommitted: false`, so report + // the flag the user actually typed rather than its positive twin. + const uncommittedFlag = options.uncommitted ? "--uncommitted" : "--no-uncommitted"; + const affectedOnlyFlags = ( + [ + ["--base", options.base], + ["--head", options.head], + ["--downstream", options.downstream], + ["--upstream", options.upstream], + [uncommittedFlag, options.uncommitted], + ] as [string, string | boolean | undefined][] + ) + .filter(([, value]) => value !== undefined) + .map(([flag]) => flag); + + if (affectedOnlyFlags.length > 0) { + throw new VisUserError( + `${affectedOnlyFlags.join(", ")} ${affectedOnlyFlags.length === 1 ? "requires" : "require"} --affected.\n` + + `Did you mean: vis run ${rawSelector} --affected ${affectedOnlyFlags.map((flag) => (flag.includes("uncommitted") ? flag : `${flag}=…`)).join(" ")}` + + `\nOr use the dedicated command: vis affected ${rawSelector} …`, + ); + } + } + + const selectorResult = await resolveSelector(rawSelector, workspace, process.cwd(), workspaceRoot); + // Resolve target aliases declared on any VisTargetConfiguration + // before any further filtering so `vis run t` picks up `test`. + const aliasMap = buildAliasMap(projectOptions); + + ctx.target = resolveTargetAlias(selectorResult.target, aliasMap); + + if (ctx.target !== selectorResult.target) { + logger.debug?.(`Resolved alias "${selectorResult.target}" → "${ctx.target}"`); + } + + ctx.projectNames = selectorResult.projects; + + // Any positional args past the target name are forwarded only to the + // invoked task — not to `dependsOn` deps. The task-runner string-form + // resolver strips overrides so deps start with a clean slate. + ctx.forwardedArgs = ctx.argument.slice(1).map(String); + + // `!== undefined`, not truthiness: `--projects=` must reach the filter + // and error rather than silently running the whole workspace. + if (options.projects !== undefined) { + ctx.projectNames = filterProjectsByNames(ctx.projectNames, String(options.projects), Object.keys(workspace.projects)); + } + + // A selection that matches nothing must not look like a pass: throw so the CLI exits + // non-zero. `--if-present` opts back into a quiet exit 0 with a one-line notice. + const onEmptySelection = (summary: string): void => { + if (options.ifPresent) { + logger.info(`${summary}; skipping (--if-present).`); + + return; + } + + throw new VisUserError(`${summary}.\n\nPass --if-present to skip quietly when nothing matches.`); + }; + + // Apply pnpm-style `--filter`/`-F` selectors. They compose with the + // selector + `--projects` set above (intersection) so a filter never + // pulls in projects the selector already excluded. Multiple `--filter` + // flags are unioned, matching pnpm. + if (options.filter && options.filter.length > 0) { + const packageNameByProject = new Map(); + + for (const [name, pkg] of ctx.packageJsons) { + packageNameByProject.set(name, pkg.name); + } + + const filtered = await applyFilterStrings(options.filter, { + defaultBase: visConfig?.defaultBase, + packageNameByProject, + projectGraph, + workspaceRoot, + }); + + const filteredSet = new Set(filtered); + + ctx.projectNames = ctx.projectNames.filter((name) => filteredSet.has(name)); + + if (ctx.projectNames.length === 0) { + onEmptySelection(`Filter ${options.filter.map((f: string) => `"${f}"`).join(", ")} matched no projects`); + + return true; + } + } + + // Apply --query filter (language=X && tag=Y style). + if (options.query) { + ctx.projectNames = filterProjectsByQuery(ctx.projectNames, workspace, options.query); + + if (ctx.projectNames.length === 0) { + onEmptySelection(`Query "${String(options.query)}" matched no projects`); + + return true; + } + } + + // `--tag` shorthand, applied as its own pass so it composes with + // `--query` regardless of which operator that query uses. + if (options.tag && options.tag.length > 0) { + ctx.projectNames = filterProjectsByTags(ctx.projectNames, workspace, options.tag); + + if (ctx.projectNames.length === 0) { + onEmptySelection(`Tag filter ${options.tag.map((t: string) => `"${t}"`).join(", ")} matched no projects`); + + return true; + } + } + + // `--affected` narrows the selection in-process rather than re-invoking + // `vis affected`. The old delegation round-trip could only forward the + // handful of flags it enumerated, so --dry-run, --fail-fast, --partition + // and friends were silently dropped on the way out. Computing here keeps + // every option on this run intact. + if (options.affected) { + const affected = await selectAffectedProjects( + { + base: options.base, + downstream: options.downstream, + head: options.head, + uncommitted: options.uncommitted, + upstream: options.upstream, + }, + { fileOwners: ctx.fileOwners, projectGraph, projects: workspace.projects, workspaceRoot }, + { affectedIgnore: visConfig?.affectedIgnore, defaultBase: visConfig?.defaultBase }, + ); + + for (const note of affected.notes) { + logger.info(`▸ ${note}`); + } + + if (affected.changedFiles.length === 0) { + logger.info("No files changed. Nothing to run."); + + return true; + } + + const affectedSet = new Set(affected.affectedProjects); + + ctx.projectNames = ctx.projectNames.filter((name) => affectedSet.has(name)); + + if (ctx.projectNames.length === 0) { + logger.info("No projects affected by the changes."); + + return true; + } + + // Paths skipped by `affectedIgnore` changed nothing, so tasks don't receive them either. + const ignoredFiles = new Set(affected.ignoredFiles); + + ctx.affectedChangedFiles = affected.changedFiles.filter((file) => !ignoredFiles.has(file)); + + logger.info(`Affected projects: ${ctx.projectNames.join(", ")}`); + } + + return false; +}; + +/** + * Phase 2: keep the projects that have the target and are allowed to run here + * (internal, runInCI, runner tags). Returns `true` when the run must stop. + */ +export const selectProjectsWithTarget = (ctx: RunContext): boolean => { + const { logger, options, projectOptions, target, workspace } = ctx; + + // `vis affected` delegates to `vis run --projects=…` and passes the + // changed-file set through the environment; an in-process `--affected` + // run already has it in hand. + const affectedFilesRaw = process.env[AFFECTED_FILES_ENV]; + + ctx.affectedFiles = ctx.affectedChangedFiles ?? (affectedFilesRaw ? affectedFilesRaw.split("\n").filter(Boolean) : undefined); + + // Runner-tag filter: CLI flag wins over env so a script can override + // a CI-injected default. Empty string after splitting is dropped so + // `--runner-tags=` (no value) doesn't accidentally enable a filter + // with one empty tag. `undefined` keeps the filter inactive. + const runnerTagsRaw = (typeof options.runnerTags === "string" ? options.runnerTags : process.env["VIS_RUNNER_TAGS"]) ?? undefined; + const runnerTags = runnerTagsRaw + ? new Set( + runnerTagsRaw + .split(",") + .map((tag: string) => tag.trim()) + .filter(Boolean), + ) + : undefined; + + for (const name of ctx.projectNames) { + const visTargets = projectOptions.get(name); + const visTarget = visTargets?.[target]; + + if (!visTarget) { + continue; + } + + const visOptions = visTarget.options; + + if (visOptions?.internal) { + continue; + } + + if (!shouldRunInCI(visOptions, Boolean(isInCi))) { + logger.debug?.(`Skipping ${name}:${target} — runInCI filter`); + continue; + } + + if (!matchesRunnerTags(visOptions, runnerTags)) { + logger.debug?.(`Skipping ${name}:${target} — runner-tags filter`); + continue; + } + + // when:/os gates are evaluated by the task-runner orchestrator + // just before launch — see TargetConfiguration.when. Letting them + // through to the graph means skipped tasks show up with a reason + // in the run summary instead of being silently filtered here. + + ctx.projectsWithTarget.push(name); + ctx.projectTargetIndex.set(name, visTarget); + } + + if (ctx.projectsWithTarget.length === 0) { + if (options.ifPresent) { + logger.info(`No projects have the "${target}" target; skipping (--if-present).`); + + return true; + } + + const available = collectAvailableTargets(workspace); + const exactMatchProjects = Object.entries(workspace.projects) + .filter(([, proj]) => proj.targets?.[target] !== undefined) + .map(([name]) => name); + + // A run that executed nothing must not look like a pass: throw so the CLI exits non-zero. + // `--if-present` (handled above) is the explicit opt-out. + const lines = [`No projects have the "${target}" target.`]; + + if (exactMatchProjects.length > 0) { + // The target exists somewhere in the workspace — it was + // just filtered out by selector/query/os/runInCI/runner-tags. + // Tell the user which projects do implement it. + lines.push("", `Target "${target}" exists in these projects but was filtered out:`, ...exactMatchProjects.slice(0, 5).map((name) => ` - ${name}`)); + + if (exactMatchProjects.length > 5) { + lines.push(` …and ${exactMatchProjects.length - 5} more`); + } + } else { + const suggestions = suggestTargets(target, available, 3); + + if (suggestions.length > 0) { + lines.push( + "", + suggestions.length === 1 ? `Did you mean "${suggestions[0]}"?` : `Did you mean one of: ${suggestions.map((s) => `"${s}"`).join(", ")}?`, + ); + } + + lines.push("", "Run `vis run` without arguments to see all available targets, or pass --if-present to skip when no project has the target."); + } + + throw new VisUserError(lines.join("\n")); + } + + return false; +}; + +/** + * Phase 3: validate the forwarded task arguments, build the initial task list, + * and split persistent tasks from regular ones. Returns `true` when the run + * must stop (`--help` for the task, or an empty partition). + */ +export const buildInitialTasks = (ctx: RunContext): boolean => { + const { logger, options, projectsWithTarget, target, workspace } = ctx; + const { projectTargetIndex } = ctx; + const ptyFlag = options.pty; + + // First-class task arguments: validate the forwarded args against the + // invoked target's declared schema, surface per-task `--help`, and expose + // validated values to the command as VIS_ARG_* env vars. See + // `./task-arguments`. Because the same args + VIS_ARG_* reach every + // selected project, all of them must agree on the contract — fail fast if + // two projects expose this target with different `arguments` schemas + // rather than validate one project's invocation against another's. + const schemasByProject = projectsWithTarget + .map((projectName) => { + return { project: projectName, schema: projectTargetIndex.get(projectName)?.arguments }; + }) + .filter( + (entry): entry is { project: string; schema: NonNullable } => + Array.isArray(entry.schema) && entry.schema.length > 0, + ); + + const distinctSchemas = new Map(); + + for (const { project, schema } of schemasByProject) { + const key = JSON.stringify(schema); + const projects = distinctSchemas.get(key) ?? []; + + projects.push(project); + distinctSchemas.set(key, projects); + } + + if (distinctSchemas.size > 1) { + const groups = [...distinctSchemas.values()].map((projects) => projects.join(", ")).join(" | "); + + throw new Error( + `Target "${target}" declares conflicting \`arguments\` schemas across projects (${groups}). ` + + `Run a single project (e.g. \`vis run ${schemasByProject[0]?.project}:${target}\` or --projects=) so the argument contract is unambiguous.`, + ); + } + + const argumentSchema = schemasByProject[0]?.schema; + const argumentDescription = projectTargetIndex.get(schemasByProject[0]?.project ?? (projectsWithTarget[0] as string))?.description; + const argumentResolution = resolveTaskArguments(target, argumentDescription, argumentSchema, ctx.forwardedArgs); + + if (argumentResolution.kind === "help") { + logger.info(argumentResolution.text); + + return true; + } + + if (argumentResolution.kind === "invalid") { + logger.info(`Invalid arguments for "${target}":`); + + for (const message of argumentResolution.errors) { + logger.info(` ✖ ${message}`); + } + + logger.info(""); + logger.info(argumentResolution.help); + + throw new Error(`Invalid arguments for target "${target}"`); + } + + const taskArgumentEnvVars = argumentResolution.env; + + // `--hash-mode` overrides how the directly-run target is hashed for + // this run; when unset, the target's configured `hashMode` (if any) + // still applies. Validated early so a bad value fails before any task + // is scheduled. Dependency tasks keep their own configured hashMode. + const hashModeOverride = parseHashMode(options.hashMode); + + const initialTasks: Task[] = projectsWithTarget.map((projectName) => { + const project = workspace.projects[projectName]; + const visTarget = projectTargetIndex.get(projectName)!; + const taskTarget: TaskTarget = { project: projectName, target }; + const taskId = `${projectName}:${target}`; + // The --pty flag flips all tasks into PTY mode; per-target config + // still wins when the target explicitly opts out. + const mergedOptions: VisTargetOptions | undefined = ptyFlag ? { ...visTarget.options, pty: visTarget.options?.pty ?? true } : visTarget.options; + + // Expand `${affected.files}` / `${changed_files | flag '...'}` + // tokens here so the resolved paths land in `overrides.command` + // before task-runner's hasher reads it. Hashing the raw form + // would let two runs with different affected-file sets but the + // same workspace file hashes collide on the cache key — the + // executor would replay a stale result that "linted" a different + // subset. Persistent tasks and the watch-retry path also read + // `overrides.command` directly, so doing this once at build time + // keeps every consumer in sync. + const expandedCommand = visTarget.command ? expandTokensInString(visTarget.command, { affectedFiles: ctx.affectedFiles, projectRoot: project?.root }) : visTarget.command; + + // Expand `{projectRoot}`/`{projectName}` tokens in outputs (string + // entries only; `{ auto: true }` passes through). Unexpanded, the + // task-runner treats `{projectRoot}/dist` as a literal glob whose braces + // match nothing, so output caching captures nothing. An empty project + // root maps to `.` so the path stays a workspace-relative `./dist` + // instead of an escaping `/dist`. Idempotent if already expanded. + const outputsProjectRoot = project?.root && project.root.length > 0 ? project.root : "."; + // OutputSpec is a `string | { auto: true }` union, so the two + // branches legitimately return different runtime shapes — the + // `{ auto: true }` sentinel passes through, strings get expanded. + // eslint-disable-next-line sonarjs/function-return-type -- OutputSpec union; non-string sentinel passes through, strings are expanded + const expandedOutputs = (visTarget.outputs ?? []).map((output): OutputSpec => { + if (typeof output !== "string") { + return output; + } + + return output.replaceAll("{projectRoot}", outputsProjectRoot).replaceAll("{projectName}", projectName); + }); + + const misplacedKeys = findMisplacedTargetKeys(visTarget.options); + + if (misplacedKeys.length > 0) { + logger.warn(describeMisplacedTargetKeys(taskId, misplacedKeys)); + } + + return { + cache: visTarget.cache, + // Mirrors the task-runner graph builder. Without it a traced target + // that declares inputs is treated as declaring none, so the declared + // fallback never runs for the directly-run target. + declaresInputs: visTarget.inputs !== undefined, + fingerprintEnv: visTarget.fingerprintEnv, + hashMode: hashModeOverride ?? visTarget.hashMode, + id: taskId, + outputs: expandedOutputs, + overrides: { + command: expandedCommand, + ...(ctx.forwardedArgs.length > 0 ? { [FORWARDED_ARGS_KEY]: ctx.forwardedArgs } : {}), + ...(Object.keys(taskArgumentEnvVars).length > 0 ? { [TASK_ARG_ENV_KEY]: taskArgumentEnvVars } : {}), + ...(mergedOptions ? { visOptions: mergedOptions } : {}), + }, + parallelism: visTarget.parallelism, + projectRoot: project?.root, + splitCompound: visTarget.splitCompound, + target: taskTarget, + untrackedEnv: visTarget.untrackedEnv, + }; + }); + + const regularTasks: Task[] = []; + + for (const task of initialTasks) { + const opts = getTaskOptions(task); + + if (opts?.persistent) { + task.cache = false; + ctx.persistentTasks.push(task); + } else { + regularTasks.push(task); + } + } + + ctx.initialTasks = regularTasks; + + const partition = parsePartition(options.partition); + + if (partition) { + ctx.initialTasks = TaskScheduler.partitionTasks(ctx.initialTasks, partition); + + logger.info(`Partition ${partition.index}/${partition.total}: running ${ctx.initialTasks.length} task(s)`); + + if (ctx.initialTasks.length === 0) { + logger.info("No tasks assigned to this partition."); + + return true; + } + } + + return false; +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts new file mode 100644 index 00000000..f1a549b4 --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts @@ -0,0 +1,355 @@ +import type { Task } from "@visulima/task-runner"; +import isInCi from "is-in-ci"; + +import { VisUserError } from "../../../errors/vis-user-error"; +import { runReadiness } from "../../../services/readiness"; +import { readAllEntries } from "../../../services/registry"; +import type { ServiceEntry } from "../../../services/types"; +import { ServiceDockStore } from "../../../tui/components/service-dock/service-dock-store"; +import { applyServiceRegistry } from "../apply-service-registry"; +import type { ServiceBridgeEntry } from "../service-event-bridge"; +import { ServiceEventBridge } from "../service-event-bridge"; +import { buildBootstrapPaths, describeServicesPolicySource, extractPreflightTasks, injectServiceTasks, resolveServicesPolicy } from "../service-preflight"; +import type { RunContext } from "./run-context"; +import { SERVICE_PROBE_TIMEOUT_MS } from "./run-persistent-tasks"; + +export type ServiceRegistryResult = Awaited>; + +/** + * Phase 6a: auto-attach to services already running in the registry. The + * probe re-runs each service's readiness check, so a dead wrapper PID is not + * attached. Returns the result for the later service phases. + */ +export const attachRegisteredServices = async (ctx: RunContext): Promise => { + const { options, workspaceRoot } = ctx; + + // Auto-attach: if a service this run depends on is already + // running in the background registry, drop it from the graph and + // capture its env for the surviving dependents. Diagnostics here + // mean the user invoked something whose dep is a service that + // isn't running — refuse to half-execute. + // + // The probe re-runs the service's readiness check with a short + // timeout before accepting the entry. PID-alive isn't enough: a + // shell-wrapped service whose underlying server has crashed leaves + // the wrapper PID alive, and we don't want to attach a dependent + // task to a dead port. The probe is skipped in `--dry-run` because + // a plan print never executes the dependent task, so probing risks + // a misleading "service down" diagnostic for what is really a + // planning-only invocation. + const visVersion = process.env["VIS_VERSION"] ?? "0.0.0"; + const probe = options.dryRun + ? undefined + : async (entry: ServiceEntry): Promise => { + try { + await runReadiness(entry.config, { timeoutMs: SERVICE_PROBE_TIMEOUT_MS }); + + return true; + } catch { + return false; + } + }; + + const registeredEntries = await readAllEntries(workspaceRoot); + + // User-invoked tasks for `applyServiceRegistry`'s "is this user-asked-for?" + // check — includes persistent ones so a user running `vis run dev` (where + // `dev` is persistent) doesn't get a confusing "service deps missing" + // diagnostic for the persistent task itself. + return applyServiceRegistry({ + initialTasks: [...ctx.initialTasks, ...ctx.persistentTasks], + probe, + registeredEntries, + taskGraph: ctx.taskGraph, + visVersion, + }); +}; + +/** + * Phase 6b: when a service dependency is not running, either boot it for this + * run (ephemeral or registry mode) or fail with a diagnostic naming the + * override. Injects the boot tasks into the graph and wires the dock + bridge. + */ +export const injectMissingServices = (ctx: RunContext, serviceResult: ServiceRegistryResult): void => { + const { config, logger, options, workspaceRoot } = ctx; + + if (serviceResult.diagnostics.length > 0) { + const isTty = Boolean(process.stdout.isTTY && process.stdin.isTTY); + const decision = options.dryRun + ? "off" + : resolveServicesPolicy({ + cli: options.services, + config: config.run?.services, + isCi: Boolean(isInCi), + isPersistentTarget: ctx.persistentTasks.length > 0, + isTty, + target: ctx.target, + }); + + if (decision === "off") { + for (const diagnostic of serviceResult.diagnostics) { + logger.error(diagnostic.message); + } + + // Auto-start exists and would have satisfied every one of these + // deps — it just wasn't reached, because `--dry-run` skips it or + // the TTY-aware default resolved to `off` (CI, a piped stdout, a + // non-interactive shell). Naming the override turns a dead end + // into a one-flag fix; without it a declarative `dependsOn` on a + // service reads as simply unsupported. + const source = describeServicesPolicySource({ cli: options.services, config: config.run?.services }); + const hints: string[] = []; + + if (options.dryRun) { + hints.push("`--dry-run` never starts services; drop it to let vis boot them."); + } else if (source === "default") { + hints.push( + "vis only auto-starts service deps in an interactive terminal by default (this run is CI or non-TTY).", + "Pass `--services=ephemeral` to boot them for this run, or set `run.services` in vis.config.ts.", + ); + } else if (source === "cli") { + hints.push("`--services=off` was passed; use `--services=ephemeral` to boot them for this run."); + } else { + hints.push("`run.services: \"off\"` is set in vis.config.ts; override it with `--services=ephemeral`."); + } + + throw new VisUserError( + [`${serviceResult.diagnostics.length} service dependency error(s) — start the missing services or invoke them directly.`, ...hints].join( + "\n", + ), + ); + } + + const missingIds = serviceResult.diagnostics.map((d) => d.targetId); + const mode = decision; + + // Capture the original (pre-injection) per-service config so the + // bridge's registry entries hold the actual service command — + // `injectServiceTasks` rewrites `task.overrides.command` to the + // `vis service start ` wrapper, which is the wrong thing to + // re-spawn from `#retryRegistry`. + const preInjectionExtracted = mode === "registry" ? extractPreflightTasks(workspaceRoot, missingIds, ctx.taskGraph) : undefined; + + // Inject the missing services as regular task graph nodes — + // they'll render as rows in the run TUI with live boot logs, + // execute strictly sequentially (artificial deps chain them), + // and exit 0 the moment readiness passes. The synthesized env + // map below mirrors apply-service-registry's prune-time logic + // without actually pruning the graph. + const visBin = process.argv[1] ?? "vis"; + const injection = injectServiceTasks({ + missingServiceIds: missingIds, + mode, + taskGraph: ctx.taskGraph, + visBin, + workspaceRoot, + }); + + if (injection.skipped.length > 0) { + for (const { id, reason } of injection.skipped) { + logger.error(`Cannot auto-start ${id}: ${reason}`); + } + + throw new VisUserError(`${injection.skipped.length} service(s) cannot be auto-started — invoke them directly or add a service config.`); + } + + ctx.ephemeralPidFiles.push(...injection.ephemeralPidFiles); + ctx.serviceRunDir = injection.runDir; + ctx.injectedServiceIds = injection.chain; + + for (const id of injection.chain) { + ctx.injectedServiceIdSet.add(id); + } + + if (mode === "registry") { + ctx.registryStartedCount = injection.chain.length; + ctx.registryStartedIds.push(...injection.chain); + } + + // Stand up the dock + bridge for both modes. Ephemeral surfaces + // boot events through the bootstrap's stdout marker channel and + // running-phase log via fs.watch. Registry surfaces them through + // the wrapper task's `started`/`close` lifecycle events emitted + // by the executor — see the bridge wiring in `onEvent`. + if (injection.chain.length > 0) { + const dock = new ServiceDockStore(injection.chain); + const bridgeEntries = new Map(); + + if (mode === "ephemeral" && injection.runDir) { + for (const id of injection.chain) { + const paths = buildBootstrapPaths(injection.runDir, `${injection.runDir}/bootstrap.mjs`, id); + + bridgeEntries.set(id, { + ephemeral: { + configFile: paths.configFile, + cwd: workspaceRoot, + logFile: paths.logFile, + pidFile: paths.pidFile, + scriptPath: paths.scriptPath, + }, + mode: "ephemeral", + }); + } + } else if (mode === "registry" && preInjectionExtracted) { + for (const svc of preInjectionExtracted.services) { + bridgeEntries.set(svc.id, { + mode: "registry", + registry: { + command: svc.command, + config: svc.config, + cwd: svc.cwd, + env: svc.env, + }, + }); + } + } + + if (bridgeEntries.size > 0) { + const bridge = new ServiceEventBridge({ + indexToId: new Map(), + services: bridgeEntries, + sink: { + crashed: (id, tail) => { + dock.markCrashed(id, tail); + }, + failed: (id, reason, detail) => { + dock.markFailed(id, reason, detail); + }, + log: (id, chunk) => { + dock.appendLog(id, chunk); + }, + ready: (id, info) => { + dock.markReady(id, info); + }, + started: (id, pid) => { + dock.markStarted(id, pid); + + // Capture registry-mode pids for the optional + // --stop-services cleanup. Ephemeral pids are + // already tracked via `ephemeralPidFiles`, so + // skip those to keep the two cleanup paths + // from doubly-killing the same pid. + if (mode === "registry" && pid !== null) { + ctx.registryStartedPids.set(id, pid); + } + }, + starting: (id) => { + dock.markStarting(id); + }, + }, + workspaceRoot, + }); + + ctx.serviceDockStore = dock; + ctx.serviceEventBridge = bridge; + } + } + + // Merge synthesized env on top of the 1st pass's env map. + // 1st-pass entries cover already-running registered services; + // injection covers services we're about to boot. + for (const [taskId, env] of injection.serviceEnvByTaskId) { + const existing = serviceResult.serviceEnvByTaskId.get(taskId) ?? {}; + + serviceResult.serviceEnvByTaskId.set(taskId, { ...existing, ...env }); + } + } +}; + +/** + * Phase 6c: strip persistent tasks back out of the regular graph, put the + * injected service boot tasks in front, and mark already-running services + * ready in the dock. + */ +export const finalizeServiceGraph = (ctx: RunContext, serviceResult: ServiceRegistryResult): void => { + const { logger } = ctx; + + // applyServiceRegistry was called with `[...initialTasks, ...persistentTasks]` + // so its "is this user-asked-for?" check covers persistent targets like + // `vis run dev`. Its returned `initialTasks` therefore *also* contains + // the persistent ones (minus any satisfied services). Strip them back + // out — `runPersistentTasks` runs them; the regular runner must not. + // Without this filter, `web:serve` is fed to *both* pipelines and the + // TUI renders the row twice. + const persistentIdSet = new Set(ctx.persistentTasks.map((task) => task.id)); + + ctx.initialTasks = serviceResult.initialTasks.filter((task) => !persistentIdSet.has(task.id)); + ctx.taskGraph = serviceResult.taskGraph; + + if (ctx.injectedServiceIds.length > 0) { + const serviceTasks = ctx.injectedServiceIds.map((id) => ctx.taskGraph.tasks[id]).filter((t): t is Task => t !== undefined); + + ctx.initialTasks = [...serviceTasks, ...ctx.initialTasks]; + } + + // Persistent tasks were folded into the graph above so their service + // deps could be walked. Strip them now so the regular task runner + // doesn't try to execute them — they have their own runner pipeline + // via `runPersistentTasks`. Their dep edges are removed too; the + // service tasks they pointed at become roots and run on their own. + if (ctx.persistentTasks.length > 0) { + const persistentIds = new Set(ctx.persistentTasks.map((task) => task.id)); + + const filteredTasks: Record = {}; + + for (const [id, task] of Object.entries(ctx.taskGraph.tasks)) { + if (!persistentIds.has(id)) { + filteredTasks[id] = task; + } + } + + const filteredDependencies: Record = {}; + + for (const [id, deps] of Object.entries(ctx.taskGraph.dependencies)) { + if (persistentIds.has(id)) { + continue; + } + + filteredDependencies[id] = deps.filter((depId) => !persistentIds.has(depId)); + } + + // Recompute roots from the trimmed graph: anything no remaining + // task depends on. Service tasks orphaned by removing their + // dependent persistent task become roots here. + const dependedOn = new Set(); + + for (const deps of Object.values(filteredDependencies)) { + for (const depId of deps) { + dependedOn.add(depId); + } + } + + const filteredRoots = Object.keys(filteredTasks).filter((id) => !dependedOn.has(id)); + + ctx.taskGraph = { + dependencies: filteredDependencies, + roots: filteredRoots, + tasks: filteredTasks, + }; + } + + if (serviceResult.satisfiedServices.length > 0) { + const names = serviceResult.satisfiedServices.map((s) => s.id).join(", "); + + logger.debug?.(`Auto-attached to running services: ${names}`); + + // Surface attached services in the dock so a second `vis run dev` + // (where the registry already holds api:db, api:redis, …) still + // shows the green pill. Without this, the dock is empty on rerun + // and the user can't tell whether their deps are wired. + if (ctx.serviceDockStore) { + for (const entry of serviceResult.satisfiedServices) { + ctx.serviceDockStore.registerService(entry.id); + } + } else { + ctx.serviceDockStore = new ServiceDockStore(serviceResult.satisfiedServices.map((s) => s.id)); + } + + for (const entry of serviceResult.satisfiedServices) { + const readinessPort = entry.config.readiness?.tcp?.port ?? entry.config.port ?? 0; + const readinessHost = entry.config.readiness?.tcp?.host ?? "127.0.0.1"; + + ctx.serviceDockStore.markReady(entry.id, { host: readinessHost, port: readinessPort }); + } + } +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/shell.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-shell.ts similarity index 69% rename from packages/tooling/vis/src/commands/run/handler-parts/shell.ts rename to packages/tooling/vis/src/commands/run/handler-parts/run-shell.ts index 4298f0d8..354d990c 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/shell.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-shell.ts @@ -2,32 +2,21 @@ import type { Task } from "@visulima/task-runner"; import type { detectCurrentOs, VisTargetOptions } from "../../../task/target-options"; import { resolveTargetShell } from "../../../task/target-options"; +import { shellQuote } from "../../../util/shell-quote"; import { getTaskOptions } from "../task-env"; /** - * Wraps a string in single quotes for safe POSIX shell execution. - * Used when we know the consumer is a POSIX shell — e.g. when forwarding - * a value as the argument of `<shell> -c <value>` regardless of OS. + * Quotes one argument for the platform's default shell. On Windows a value + * with no special characters is passed bare; everything else, and every + * POSIX value, goes through the shared `shellQuote` (single quotes on POSIX, + * cmd.exe double quotes on Windows). */ -const singleQuoteEscape = (value: string): string => `'${value.replaceAll("'", String.raw`'\''`)}'`; - -/** - * Wraps a string for safe shell execution by the platform's default - * shell. POSIX uses single quotes (prevents `$VAR` / `\n` / backtick - * expansion). Windows cmd.exe treats single quotes as literal characters - * — they would survive into the child process's argv — so we use - * double-quote escaping with inner `"` doubled per cmd.exe parsing rules. - */ -const shellQuote = (value: string): string => { - if (process.platform === "win32") { - if (value.length > 0 && !/[\s"&|<>^()%!]/.test(value)) { - return value; - } - - return `"${value.replaceAll("\"", "\"\"")}"`; +const quoteArgument = (value: string): string => { + if (process.platform === "win32" && value.length > 0 && !/[\s"&|<>^()%!]/.test(value)) { + return value; } - return singleQuoteEscape(value); + return shellQuote(value); }; /** Builds the command args list for `affectedFiles` forwarding. */ @@ -37,7 +26,7 @@ const buildAffectedFilesArgs = (command: string, affectedFiles: string[] | undef } if (mode === "args" || mode === "both") { - const quoted = affectedFiles.map((file) => shellQuote(file)).join(" "); + const quoted = affectedFiles.map((file) => quoteArgument(file)).join(" "); return `${command} ${quoted}`; } @@ -61,7 +50,7 @@ const appendForwardedArgs = (command: string, task: Task): string => { return command; } - const quoted = (args as string[]).map((argument) => shellQuote(argument)).join(" "); + const quoted = (args as string[]).map((argument) => quoteArgument(argument)).join(" "); return `${command} ${quoted}`; }; @@ -96,9 +85,9 @@ export const resolveTaskCommand = (task: Task, affectedFiles: string[] | undefin // into a file lookup), so fall back to `-c`. const shellArgs = visOptions?.shellArgs; const shellInvokeArgs = shellArgs && shellArgs.length > 0 ? shellArgs.join(" ") : "-c"; - // `shellQuote` is platform-aware (POSIX single-quote / cmd.exe - // double-quote) — `singleQuoteEscape` would leave literal `'…'` around + // `quoteArgument` is platform-aware (POSIX single-quote / cmd.exe + // double-quote) — a POSIX-only quote would leave literal `'…'` around // the command under cmd.exe, breaking e.g. `node -e` on Windows. - return customShell ? `${customShell} ${shellInvokeArgs} ${shellQuote(commandWithAffected)}` : commandWithAffected; + return customShell ? `${customShell} ${shellInvokeArgs} ${quoteArgument(commandWithAffected)}` : commandWithAffected; }; diff --git a/packages/tooling/vis/src/commands/run/handler.ts b/packages/tooling/vis/src/commands/run/handler.ts index 420147ee..c196c52c 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -1,2163 +1,67 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; -import type { ChildEnvOptions, LogMode, OutputSpec, ProcessEvent, TargetConfiguration, Task, TaskResults, TaskRunnerOptions, TaskTarget } from "@visulima/task-runner"; -import { CompositeLifeCycle, createLogReporter, createTaskGraph, defaultTaskRunner, enforceProjectConstraints, expandTokensInString, generateRunSummary, parsePartition, pickChildParentEnv, resolveTurboEnvCompat, reverseTaskGraph, runConcurrently, splitCompoundTasks, TaskScheduler, TerminalBuffer, writeChromeTrace, writeRunSummary } from "@visulima/task-runner"; -import isInCi from "is-in-ci"; -import { applyBranchScope, resolveSharedCacheDirectory } from "../../cache/cache-directory"; -import { buildProjectGraph, discoverWorkspace, loadVisTaskConfigsForWorkspace } from "../../config/workspace"; -import { VisUserError } from "../../errors/vis-user-error"; -import { runLockfilePreflight } from "../../preflight/lockfile"; -import { maybePromptViteClientOverride } from "../../preflight/vite-client-override"; -import { FailureLogLifeCycle } from "../../report/failure-log"; -import { analyzeFlakiness, formatFlakinessTable } from "../../report/flakiness"; -import { compareDuration, formatTimingSummary, loadRunSummaries } from "../../report/run-report"; -import { resolveCommandRuntime } from "../../runtime/command-runtime"; -import { runToolchainPreflight } from "../../runtime/toolchain"; -import { runReadiness } from "../../services/readiness"; -import { deleteEntry, readAllEntries } from "../../services/registry"; -import type { ServiceEntry } from "../../services/types"; -import { selectAffectedProjects } from "../../task/affected-selection"; -import { describeMisplacedTargetKeys, findMisplacedTargetKeys } from "../../task/misplaced-options"; -import { filterProjectsByNames } from "../../task/project-name-filter"; -import { filterProjectsByQuery, filterProjectsByTags, resolveSelector } from "../../task/selectors"; -import { resolveSkipCachePatterns } from "../../task/skip-cache"; -import { checkStrictEnv, formatStrictEnvError } from "../../task/strict-env"; -import { buildAliasMap, collectAvailableTargets, formatTargetList, promptTargetInteractively, resolveTargetAlias, suggestTargets } from "../../task/target-discovery"; -import type { VisTargetConfiguration, VisTargetOptions } from "../../task/target-options"; -import { detectCurrentOs, matchesRunnerTags, resolveTargetShell, shouldRunInCI } from "../../task/target-options"; -import { ServiceDockStore } from "../../tui/components/service-dock/service-dock-store"; -import { createDynamicOutputRenderer } from "../../tui/dynamic-life-cycle"; -import { resolveOutputStyle, StaticOutputLifeCycle } from "../../tui/static-life-cycle"; -import type { StdinEntry } from "../../tui/types"; -import { createVisHooks, HookableLifeCycle, registerPlugins } from "../../util/hooks"; -import { appendToShellHistory } from "../../util/shell-history"; -import { cleanupLegacyTaskRunnerLayout, getVisWorkspaceDataDir } from "../../util/vis-paths"; -import { collectTrackedWatchTargets, createTrackedFileFilter, startWatcher } from "../../watch/watch"; -import { applyProjectFilter } from "../../watch/watch-filter"; -import type { KeybindHandle } from "../../watch/watch-keybinds"; -import { installKeybinds, writeHelp } from "../../watch/watch-keybinds"; -import { applyServiceRegistry } from "./apply-service-registry"; -import { applyFilterStrings } from "./filter"; -import type { MutexPool } from "./handler-parts/executor"; -import { createConcurrentExecutor, createRetryBudget } from "./handler-parts/executor"; -import { MAX_OUTPUT_BYTES } from "./handler-parts/output"; -import { parseCacheBackend, parseCacheMode, parseEnvConcurrency, parseHashMode, renderLastRunSummary } from "./handler-parts/parse"; -import { cleanupEphemeralServices, runPersistentTasks, SERVICE_PROBE_TIMEOUT_MS } from "./handler-parts/persistent-tasks"; -import { FORWARDED_ARGS_KEY, resolveTaskCommand } from "./handler-parts/shell"; +import { buildRunGraph, printDryRunPlan, runPreflightChecks, shapeTaskGraph } from "./handler-parts/run-graph"; +import { runInteractiveMode, runStaticMode } from "./handler-parts/run-modes"; +import { armRunCleanup, prepareRunRuntime, teardownRun } from "./handler-parts/run-runtime"; +import { buildInitialTasks, loadRunWorkspace, selectProjectsWithTarget, selectRunProjects } from "./handler-parts/run-select"; +import { attachRegisteredServices, finalizeServiceGraph, injectMissingServices } from "./handler-parts/run-services"; import type { RunOptions } from "./index"; -import type { ServiceBridgeEntry } from "./service-event-bridge"; -import { ServiceEventBridge } from "./service-event-bridge"; -import { buildBootstrapPaths, describeServicesPolicySource, extractPreflightTasks, injectServiceTasks, resolveServicesPolicy } from "./service-preflight"; -import { resolveTaskArguments } from "./task-arguments"; -import { AFFECTED_FILES_ENV, createTaskSpawnContext, effectiveChildEnvPolicy, getTaskOptions, resolveRetrySpawn, resolveTaskSpawn, TASK_ARG_ENV_KEY } from "./task-env"; +/** + * `vis run TARGET`: select projects, build and preflight the task graph, + * attach or boot services, then run the tasks (interactive, static, or watch). + * Each phase lives in `./handler-parts/run-*.ts`; a phase returning `true` + * (or `undefined` from the workspace load) ends the run early. + */ const execute = async ({ argument, logger, options, visConfig, workspaceRoot: wsRoot }: Toolbox): Promise => { if (!wsRoot) { throw new Error("Could not determine workspace root. Run this command inside a monorepo."); } - const workspaceRoot = wsRoot; - - // Validate the selected runtime (--runtime / VIS_RUNTIME / config). `run` - // orchestrates tool binaries (tsc, vite, eslint, …) that are runtime-agnostic, - // so it deliberately does NOT change task execution based on the runtime — and - // must not inject it into the task env, which would rotate every task's cache - // hash. We only validate and resolve the runtime, matching the PM verbs. - // Runtime-specific *execution* lives in `vis x` (file runner) and the PM verbs, - // not in task orchestration. - resolveCommandRuntime({ options, visConfig }, workspaceRoot); - - // Vis used to write its run state under `.task-runner/` and the cache - // under `.task-runner-cache/`. After the cutover to `.vis/` those paths - // are no longer read, so silently sweep them away on the next run. - cleanupLegacyTaskRunnerLayout(workspaceRoot); - - // `--last-details` short-circuits execution: render the - // persisted last-run summary and exit. Handles the common - // "I ran tests 30s ago, show me what happened" case without - // re-running and (more importantly) without needing the - // workspace to be in a runnable state. - if (options.lastDetails) { - await renderLastRunSummary(workspaceRoot, logger); + const ctx = await loadRunWorkspace({ argument, logger, options, visConfig, workspaceRoot: wsRoot }); + if (!ctx) { return; } - // The directory the user invoked `vis` from, captured before any - // workspace resolution. Propagated as INIT_CWD to every task — - // matches pnpm/npm/yarn semantics so scripts can resolve paths - // relative to where the user actually ran the command. - const invocationCwd = process.cwd(); - const taskConfigs = await loadVisTaskConfigsForWorkspace(workspaceRoot, visConfig); - const { config, fileOwners, packageJsons, projectOptions, workspace } = discoverWorkspace(workspaceRoot, visConfig, taskConfigs); - const projectGraph = buildProjectGraph(workspaceRoot, workspace, packageJsons); - - let rawSelector = argument[0]; - - if (!rawSelector) { - const available = collectAvailableTargets(workspace); - - if (process.stdout.isTTY && process.stdin.isTTY) { - const picked = await promptTargetInteractively(available); - - if (!picked) { - logger.info("No target selected."); - - return; - } - - rawSelector = picked; - - // The user ran `vis run` (no target) — without this, the - // resolved pick never appears in shell history, so - // up-arrow replays the picker, not the task. Best-effort, - // skipped on Windows / unrecognized shell. - await appendToShellHistory(`vis run ${picked}`); - } else { - // Non-interactive and no target: there is nothing to pick and - // nothing to run. Returning 0 here made the missing target a - // *passing* CI job that printed a target list where its task - // output should have been. - logger.info("Available targets:"); - logger.info(""); - logger.info(formatTargetList(available)); - logger.info(""); - - throw new VisUserError("Missing target. Usage: vis run "); - } - } - - if (config.constraints && !options.skipConstraints) { - const violations = enforceProjectConstraints(projectGraph, config.constraints); - - if (violations.length > 0) { - for (const v of violations) { - logger.error(`[${v.rule}] ${v.message}`); - } - - throw new Error(`${violations.length} project constraint violation(s) found. Use --skip-constraints to bypass.`); - } - } - - // The `--affected` modifiers are meaningless on their own. Reject them - // rather than accepting a flag that changes nothing — silently ignoring - // `--base` here is how a CI job ends up "passing" without running. - if (!options.affected) { - // `--no-uncommitted` arrives here as `uncommitted: false`, so report - // the flag the user actually typed rather than its positive twin. - const uncommittedFlag = options.uncommitted ? "--uncommitted" : "--no-uncommitted"; - const affectedOnlyFlags = ( - [ - ["--base", options.base], - ["--head", options.head], - ["--downstream", options.downstream], - ["--upstream", options.upstream], - [uncommittedFlag, options.uncommitted], - ] as [string, string | boolean | undefined][] - ) - .filter(([, value]) => value !== undefined) - .map(([flag]) => flag); - - if (affectedOnlyFlags.length > 0) { - throw new VisUserError( - `${affectedOnlyFlags.join(", ")} ${affectedOnlyFlags.length === 1 ? "requires" : "require"} --affected.\n` - + `Did you mean: vis run ${rawSelector} --affected ${affectedOnlyFlags.map((flag) => (flag.includes("uncommitted") ? flag : `${flag}=…`)).join(" ")}` - + `\nOr use the dedicated command: vis affected ${rawSelector} …`, - ); - } - } - - const selectorResult = await resolveSelector(rawSelector, workspace, process.cwd(), workspaceRoot); - // Resolve target aliases declared on any VisTargetConfiguration - // before any further filtering so `vis run t` picks up `test`. - const aliasMap = buildAliasMap(projectOptions); - const target = resolveTargetAlias(selectorResult.target, aliasMap); - - if (target !== selectorResult.target) { - logger.debug?.(`Resolved alias "${selectorResult.target}" → "${target}"`); - } - - let projectNames = selectorResult.projects; - - // Any positional args past the target name are forwarded only to the - // invoked task — not to `dependsOn` deps. The task-runner string-form - // resolver strips overrides so deps start with a clean slate. - const forwardedArgs: string[] = argument.slice(1).map(String); - - // `!== undefined`, not truthiness: `--projects=` must reach the filter - // and error rather than silently running the whole workspace. - if (options.projects !== undefined) { - projectNames = filterProjectsByNames(projectNames, String(options.projects), Object.keys(workspace.projects)); - } - - // A selection that matches nothing must not look like a pass: throw so the CLI exits - // non-zero. `--if-present` opts back into a quiet exit 0 with a one-line notice. - const onEmptySelection = (summary: string): void => { - if (options.ifPresent) { - logger.info(`${summary}; skipping (--if-present).`); - - return; - } - - throw new VisUserError(`${summary}.\n\nPass --if-present to skip quietly when nothing matches.`); - }; - - // Apply pnpm-style `--filter`/`-F` selectors. They compose with the - // selector + `--projects` set above (intersection) so a filter never - // pulls in projects the selector already excluded. Multiple `--filter` - // flags are unioned, matching pnpm. - if (options.filter && options.filter.length > 0) { - const packageNameByProject = new Map(); - - for (const [name, pkg] of packageJsons) { - packageNameByProject.set(name, pkg.name); - } - - const filtered = await applyFilterStrings(options.filter, { - defaultBase: visConfig?.defaultBase, - packageNameByProject, - projectGraph, - workspaceRoot, - }); - - const filteredSet = new Set(filtered); - - projectNames = projectNames.filter((name) => filteredSet.has(name)); - - if (projectNames.length === 0) { - onEmptySelection(`Filter ${options.filter.map((f: string) => `"${f}"`).join(", ")} matched no projects`); - - return; - } - } - - // Apply --query filter (language=X && tag=Y style). - if (options.query) { - projectNames = filterProjectsByQuery(projectNames, workspace, options.query); - - if (projectNames.length === 0) { - onEmptySelection(`Query "${String(options.query)}" matched no projects`); - - return; - } - } - - // `--tag` shorthand, applied as its own pass so it composes with - // `--query` regardless of which operator that query uses. - if (options.tag && options.tag.length > 0) { - projectNames = filterProjectsByTags(projectNames, workspace, options.tag); - - if (projectNames.length === 0) { - onEmptySelection(`Tag filter ${options.tag.map((t: string) => `"${t}"`).join(", ")} matched no projects`); - - return; - } - } - - // `--affected` narrows the selection in-process rather than re-invoking - // `vis affected`. The old delegation round-trip could only forward the - // handful of flags it enumerated, so --dry-run, --fail-fast, --partition - // and friends were silently dropped on the way out. Computing here keeps - // every option on this run intact. - let affectedChangedFiles: string[] | undefined; - - if (options.affected) { - const affected = await selectAffectedProjects( - { - base: options.base, - downstream: options.downstream, - head: options.head, - uncommitted: options.uncommitted, - upstream: options.upstream, - }, - { fileOwners, projectGraph, projects: workspace.projects, workspaceRoot }, - { affectedIgnore: visConfig?.affectedIgnore, defaultBase: visConfig?.defaultBase }, - ); - - for (const note of affected.notes) { - logger.info(`▸ ${note}`); - } - - if (affected.changedFiles.length === 0) { - logger.info("No files changed. Nothing to run."); - - return; - } - - const affectedSet = new Set(affected.affectedProjects); - - projectNames = projectNames.filter((name) => affectedSet.has(name)); - - if (projectNames.length === 0) { - logger.info("No projects affected by the changes."); - - return; - } - - // Paths skipped by `affectedIgnore` changed nothing, so tasks don't receive them either. - const ignoredFiles = new Set(affected.ignoredFiles); - - affectedChangedFiles = affected.changedFiles.filter((file) => !ignoredFiles.has(file)); - - logger.info(`Affected projects: ${projectNames.join(", ")}`); - } - - const currentOs = detectCurrentOs(); - - // `vis affected` delegates to `vis run --projects=…` and passes the - // changed-file set through the environment; an in-process `--affected` - // run already has it in hand. - const affectedFilesRaw = process.env[AFFECTED_FILES_ENV]; - const affectedFiles = affectedChangedFiles ?? (affectedFilesRaw ? affectedFilesRaw.split("\n").filter(Boolean) : undefined); - - // Runner-tag filter: CLI flag wins over env so a script can override - // a CI-injected default. Empty string after splitting is dropped so - // `--runner-tags=` (no value) doesn't accidentally enable a filter - // with one empty tag. `undefined` keeps the filter inactive. - const runnerTagsRaw = (typeof options.runnerTags === "string" ? options.runnerTags : process.env["VIS_RUNNER_TAGS"]) ?? undefined; - const runnerTags = runnerTagsRaw - ? new Set( - runnerTagsRaw - .split(",") - .map((tag: string) => tag.trim()) - .filter(Boolean), - ) - : undefined; - - const projectsWithTarget: string[] = []; - const projectTargetIndex = new Map(); - - for (const name of projectNames) { - const visTargets = projectOptions.get(name); - const visTarget = visTargets?.[target]; - - if (!visTarget) { - continue; - } - - const visOptions = visTarget.options; - - if (visOptions?.internal) { - continue; - } - - if (!shouldRunInCI(visOptions, Boolean(isInCi))) { - logger.debug?.(`Skipping ${name}:${target} — runInCI filter`); - continue; - } - - if (!matchesRunnerTags(visOptions, runnerTags)) { - logger.debug?.(`Skipping ${name}:${target} — runner-tags filter`); - continue; - } - - // when:/os gates are evaluated by the task-runner orchestrator - // just before launch — see TargetConfiguration.when. Letting them - // through to the graph means skipped tasks show up with a reason - // in the run summary instead of being silently filtered here. - - projectsWithTarget.push(name); - projectTargetIndex.set(name, visTarget); - } - - if (projectsWithTarget.length === 0) { - if (options.ifPresent) { - logger.info(`No projects have the "${target}" target; skipping (--if-present).`); - - return; - } - - const available = collectAvailableTargets(workspace); - const exactMatchProjects = Object.entries(workspace.projects) - .filter(([, proj]) => proj.targets?.[target] !== undefined) - .map(([name]) => name); - - // A run that executed nothing must not look like a pass: throw so the CLI exits non-zero. - // `--if-present` (handled above) is the explicit opt-out. - const lines = [`No projects have the "${target}" target.`]; - - if (exactMatchProjects.length > 0) { - // The target exists somewhere in the workspace — it was - // just filtered out by selector/query/os/runInCI/runner-tags. - // Tell the user which projects do implement it. - lines.push("", `Target "${target}" exists in these projects but was filtered out:`, ...exactMatchProjects.slice(0, 5).map((name) => ` - ${name}`)); - - if (exactMatchProjects.length > 5) { - lines.push(` …and ${exactMatchProjects.length - 5} more`); - } - } else { - const suggestions = suggestTargets(target, available, 3); - - if (suggestions.length > 0) { - lines.push( - "", - suggestions.length === 1 ? `Did you mean "${suggestions[0]}"?` : `Did you mean one of: ${suggestions.map((s) => `"${s}"`).join(", ")}?`, - ); - } - - lines.push("", "Run `vis run` without arguments to see all available targets, or pass --if-present to skip when no project has the target."); - } - - throw new VisUserError(lines.join("\n")); - } - - const ptyFlag = options.pty; - - // First-class task arguments: validate the forwarded args against the - // invoked target's declared schema, surface per-task `--help`, and expose - // validated values to the command as VIS_ARG_* env vars. See - // `./task-arguments`. Because the same args + VIS_ARG_* reach every - // selected project, all of them must agree on the contract — fail fast if - // two projects expose this target with different `arguments` schemas - // rather than validate one project's invocation against another's. - const schemasByProject = projectsWithTarget - .map((projectName) => { - return { project: projectName, schema: projectTargetIndex.get(projectName)?.arguments }; - }) - .filter( - (entry): entry is { project: string; schema: NonNullable } => - Array.isArray(entry.schema) && entry.schema.length > 0, - ); - - const distinctSchemas = new Map(); - - for (const { project, schema } of schemasByProject) { - const key = JSON.stringify(schema); - const projects = distinctSchemas.get(key) ?? []; - - projects.push(project); - distinctSchemas.set(key, projects); - } - - if (distinctSchemas.size > 1) { - const groups = [...distinctSchemas.values()].map((projects) => projects.join(", ")).join(" | "); - - throw new Error( - `Target "${target}" declares conflicting \`arguments\` schemas across projects (${groups}). ` - + `Run a single project (e.g. \`vis run ${schemasByProject[0]?.project}:${target}\` or --projects=) so the argument contract is unambiguous.`, - ); - } - - const argumentSchema = schemasByProject[0]?.schema; - const argumentDescription = projectTargetIndex.get(schemasByProject[0]?.project ?? (projectsWithTarget[0] as string))?.description; - const argumentResolution = resolveTaskArguments(target, argumentDescription, argumentSchema, forwardedArgs); - - if (argumentResolution.kind === "help") { - logger.info(argumentResolution.text); - + if (await selectRunProjects(ctx)) { return; } - if (argumentResolution.kind === "invalid") { - logger.info(`Invalid arguments for "${target}":`); - - for (const message of argumentResolution.errors) { - logger.info(` ✖ ${message}`); - } - - logger.info(""); - logger.info(argumentResolution.help); - - throw new Error(`Invalid arguments for target "${target}"`); - } - - const taskArgumentEnvVars = argumentResolution.env; - - // `--hash-mode` overrides how the directly-run target is hashed for - // this run; when unset, the target's configured `hashMode` (if any) - // still applies. Validated early so a bad value fails before any task - // is scheduled. Dependency tasks keep their own configured hashMode. - const hashModeOverride = parseHashMode(options.hashMode); - - let initialTasks: Task[] = projectsWithTarget.map((projectName) => { - const project = workspace.projects[projectName]; - const visTarget = projectTargetIndex.get(projectName)!; - const taskTarget: TaskTarget = { project: projectName, target }; - const taskId = `${projectName}:${target}`; - // The --pty flag flips all tasks into PTY mode; per-target config - // still wins when the target explicitly opts out. - const mergedOptions: VisTargetOptions | undefined = ptyFlag ? { ...visTarget.options, pty: visTarget.options?.pty ?? true } : visTarget.options; - - // Expand `${affected.files}` / `${changed_files | flag '...'}` - // tokens here so the resolved paths land in `overrides.command` - // before task-runner's hasher reads it. Hashing the raw form - // would let two runs with different affected-file sets but the - // same workspace file hashes collide on the cache key — the - // executor would replay a stale result that "linted" a different - // subset. Persistent tasks and the watch-retry path also read - // `overrides.command` directly, so doing this once at build time - // keeps every consumer in sync. - const expandedCommand = visTarget.command ? expandTokensInString(visTarget.command, { affectedFiles, projectRoot: project?.root }) : visTarget.command; - - // Expand `{projectRoot}`/`{projectName}` tokens in outputs (string - // entries only; `{ auto: true }` passes through). Unexpanded, the - // task-runner treats `{projectRoot}/dist` as a literal glob whose braces - // match nothing, so output caching captures nothing. An empty project - // root maps to `.` so the path stays a workspace-relative `./dist` - // instead of an escaping `/dist`. Idempotent if already expanded. - const outputsProjectRoot = project?.root && project.root.length > 0 ? project.root : "."; - // OutputSpec is a `string | { auto: true }` union, so the two - // branches legitimately return different runtime shapes — the - // `{ auto: true }` sentinel passes through, strings get expanded. - // eslint-disable-next-line sonarjs/function-return-type -- OutputSpec union; non-string sentinel passes through, strings are expanded - const expandedOutputs = (visTarget.outputs ?? []).map((output): OutputSpec => { - if (typeof output !== "string") { - return output; - } - - return output.replaceAll("{projectRoot}", outputsProjectRoot).replaceAll("{projectName}", projectName); - }); - - const misplacedKeys = findMisplacedTargetKeys(visTarget.options); - - if (misplacedKeys.length > 0) { - logger.warn(describeMisplacedTargetKeys(taskId, misplacedKeys)); - } - - return { - cache: visTarget.cache, - // Mirrors the task-runner graph builder. Without it a traced target - // that declares inputs is treated as declaring none, so the declared - // fallback never runs for the directly-run target. - declaresInputs: visTarget.inputs !== undefined, - fingerprintEnv: visTarget.fingerprintEnv, - hashMode: hashModeOverride ?? visTarget.hashMode, - id: taskId, - outputs: expandedOutputs, - overrides: { - command: expandedCommand, - ...(forwardedArgs.length > 0 ? { [FORWARDED_ARGS_KEY]: forwardedArgs } : {}), - ...(Object.keys(taskArgumentEnvVars).length > 0 ? { [TASK_ARG_ENV_KEY]: taskArgumentEnvVars } : {}), - ...(mergedOptions ? { visOptions: mergedOptions } : {}), - }, - parallelism: visTarget.parallelism, - projectRoot: project?.root, - splitCompound: visTarget.splitCompound, - target: taskTarget, - untrackedEnv: visTarget.untrackedEnv, - }; - }); - - const persistentTasks: Task[] = []; - const regularTasks: Task[] = []; - - for (const task of initialTasks) { - const opts = getTaskOptions(task); - - if (opts?.persistent) { - task.cache = false; - persistentTasks.push(task); - } else { - regularTasks.push(task); - } - } - - initialTasks = regularTasks; - - const partition = parsePartition(options.partition); - - if (partition) { - initialTasks = TaskScheduler.partitionTasks(initialTasks, partition); - - logger.info(`Partition ${partition.index}/${partition.total}: running ${initialTasks.length} task(s)`); - - if (initialTasks.length === 0) { - logger.info("No tasks assigned to this partition."); - - return; - } - } - - // User-requested task ids (post-partition), so the dependency-hydration - // pass below can tell a `dependsOn`-added task apart from one the user - // asked for directly and only warn about missing commands on the former. - const requestedTaskIds = new Set([...initialTasks, ...persistentTasks].map((task) => task.id)); - - // Include persistent tasks in the graph so their `dependsOn` chain - // (especially service deps like `web:serve → api:db`) is walked by - // `applyServiceRegistry`. Without this, persistent tasks are split - // out before graph construction and their service deps go undiscovered, - // so preflight never auto-starts them. The persistent task ids are - // stripped from the graph again after preflight injection so the - // regular runner doesn't execute them — they run via `runPersistentTasks`. - let taskGraph = createTaskGraph([...initialTasks, ...persistentTasks], { - onCycleBroken: (cycle) => { - // A dependency cycle that runs only through devDependency edges - // is tolerated (pnpm does the same) — we break it and warn rather - // than deadlocking. Cycles with a real (static) edge stay fatal. - logger.warn(`Ignoring dev-only dependency cycle (build order is ambiguous): ${cycle.join(" → ")}`); - }, - onUnmatchedProjectSelector: (selector) => { - // An unmatched `dependsOn.projects` entry produces no edge, so - // the declared ordering just isn't there. Silence made a typo - // and a working config indistinguishable. - logger.warn(`dependsOn.projects entry "${selector}" matched no project — that dependency edge was not created.`); - }, - projectGraph, - targetDefaults: config.tasks as unknown as Record>, - workspace, - }); - - // task-runner's createTaskGraph copies overrides only from the - // user-invoked `initialTasks`, so dep tasks added via `dependsOn` - // arrive without `visOptions` and without `command`. That hides - // every vis-specific option on dep tasks — most importantly - // `options.service`, which applyServiceRegistry needs to detect a - // service dependency it should auto-attach to — and leaves the - // service-preflight injector with no command to wrap. Re-hydrate - // both fields for every task in the graph from `projectOptions` - // so dep tasks carry the same target metadata that user-invoked - // tasks already carry. - for (const [taskId, task] of Object.entries(taskGraph.tasks)) { - const projectName = task.target.project; - const targetName = task.target.target; - const visTarget = projectOptions.get(projectName)?.[targetName]; - - if (!visTarget) { - // A `dependsOn`-added dependency that resolves to a target with - // no configuration anywhere (e.g. `dependsOn: ["codegen"]` but - // the project has no `codegen` script/target). task-runner keeps - // it in the graph and the executor runs it as a silent 0-exit - // no-op, so the missing prerequisite vanishes instead of erroring - // — the trap that makes such gaps CI-only and hard to find. Warn - // so the misconfiguration is visible. Tasks the user invoked - // directly always carry config, so this only fires for deps. - if (!requestedTaskIds.has(taskId)) { - logger.warn(`${targetName} required via dependsOn but no command is configured for ${projectName} — skipping (no-op).`); - } - - continue; - } - - const project = workspace.projects[projectName]; - let mutated = false; - const nextOverrides = { ...task.overrides }; - - if (nextOverrides["visOptions"] === undefined && visTarget.options) { - nextOverrides["visOptions"] = visTarget.options; - mutated = true; - } - - if (nextOverrides["command"] === undefined && visTarget.command) { - nextOverrides["command"] = expandTokensInString(visTarget.command, { affectedFiles, projectRoot: project?.root }); - mutated = true; - } - - if (mutated) { - task.overrides = nextOverrides; - taskGraph.tasks[taskId] = task; - } - } - - // Per-task cache bypass via --skip-cache=. Unlike the - // global --no-cache (which drops the runner's `skipNxCache` switch - // and bypasses cache for every task), this only flips - // `task.cache = false` on tasks whose ID matches a user-supplied - // pattern. The dry-run plan printer already surfaces this as - // `(no-cache)` next to the matched task IDs. We skip the work - // entirely when --no-cache is set since the runner-level switch - // already disables caching for the whole run. - if (typeof options.skipCache === "string" && options.skipCache.trim() !== "") { - if (options.cache) { - const skipResolution = resolveSkipCachePatterns(options.skipCache, workspace, Object.keys(taskGraph.tasks)); - - for (const id of skipResolution.skipTaskIds) { - const task = taskGraph.tasks[id]; - - if (task !== undefined) { - task.cache = false; - } - } - - if (skipResolution.unmatchedPatterns.length > 0) { - logger.warn(`--skip-cache: no tasks matched ${skipResolution.unmatchedPatterns.map((p) => `"${p}"`).join(", ")}`); - } - - if (skipResolution.skipTaskIds.size > 0) { - logger.debug?.(`--skip-cache: bypassing cache for ${String(skipResolution.skipTaskIds.size)} task(s)`); - } - } else { - logger.debug?.("--skip-cache ignored: --no-cache already disables caching for the whole run"); - } - } - - // Pre-flight: if a workspace tool pin doesn't match the running - // version and `toolchain.autoInstall` is on (default when a - // manager is detected), install via the right manager and let - // subsequent task subprocesses pick up the new version. We - // never block on failure — surface a warning, keep going, - // and let the existing runtime-check warnings do their job. - // - // Runs before service auto-attach so the right Node/PM is in - // place when the probe runs, and before the dry-run short-circuit - // so `--dry-run` still surfaces toolchain drift. - await runToolchainPreflight( - workspaceRoot, - config.toolchain, - { - error: (message) => { - logger.error(message); - }, - info: (message) => { - logger.info(message); - }, - warn: (message) => { - logger.warn(message); - }, - }, - Boolean(options.skipToolchain), - ); - - // Lockfile/install drift check. Fires after toolchain (so the - // suggested install command runs against the right Node) and - // before service auto-attach + task execution. CLI flag wins over - // config; both default to on. The helper logs the warn line itself - // in TTY; in CI it stays silent and we throw with `formattedMessage` - // so the user sees the detail exactly once. - const preflightEnabled = options.preflight !== false && config.preflight?.lockfile !== false; - const lockfilePreflight = runLockfilePreflight( - workspaceRoot, - isInCi, - { - warn: (message) => { - logger.warn(message); - }, - }, - { skip: !preflightEnabled }, - ); - - if (!lockfilePreflight.shouldContinue) { - throw new Error(`${lockfilePreflight.formattedMessage ?? "preflight: lockfile drift detected"} (pass --no-preflight to bypass)`); - } - - // One-time offer to alias @voidzero-dev/vite-task-client to our - // drop-in client so its cache hints reach the runner. TTY-only and - // remembers a decline, so CI and repeat runs are never blocked. - if (preflightEnabled && !isInCi && process.stdin.isTTY && process.stdout.isTTY) { - await maybePromptViteClientOverride(workspaceRoot, { - interactive: true, - logger: { - info: (message) => { - logger.info(message); - }, - warn: (message) => { - logger.warn(message); - }, - }, - // Scan every discovered project manifest, not just the root — - // a tool depending on the vite client may live in a sub-package. - projectManifests: [...packageJsons.values()], - }); - } - - // Auto-attach: if a service this run depends on is already - // running in the background registry, drop it from the graph and - // capture its env for the surviving dependents. Diagnostics here - // mean the user invoked something whose dep is a service that - // isn't running — refuse to half-execute. - // - // The probe re-runs the service's readiness check with a short - // timeout before accepting the entry. PID-alive isn't enough: a - // shell-wrapped service whose underlying server has crashed leaves - // the wrapper PID alive, and we don't want to attach a dependent - // task to a dead port. The probe is skipped in `--dry-run` because - // a plan print never executes the dependent task, so probing risks - // a misleading "service down" diagnostic for what is really a - // planning-only invocation. - const visVersion = process.env["VIS_VERSION"] ?? "0.0.0"; - const probe = options.dryRun - ? undefined - : async (entry: ServiceEntry): Promise => { - try { - await runReadiness(entry.config, { timeoutMs: SERVICE_PROBE_TIMEOUT_MS }); - - return true; - } catch { - return false; - } - }; - - const registeredEntries = await readAllEntries(workspaceRoot); - // User-invoked tasks for `applyServiceRegistry`'s "is this user-asked-for?" - // check — includes persistent ones so a user running `vis run dev` (where - // `dev` is persistent) doesn't get a confusing "service deps missing" - // diagnostic for the persistent task itself. - const serviceResult = await applyServiceRegistry({ - initialTasks: [...initialTasks, ...persistentTasks], - probe, - registeredEntries, - taskGraph, - visVersion, - }); - - /** - * Ephemeral pid files written by the bootstrap wrapper at service - * boot. Read at run end + SIGTERM the recorded pids so a failed run - * never leaves orphan children. Registry-mode services are NOT - * tracked here — they persist by design. - */ - const ephemeralPidFiles: string[] = []; - /** Per-run scratch directory holding the bootstrap script + pid/config files. Removed at run end. */ - let serviceRunDir: string | undefined; - /** Registry-mode services started this run; surfaced in a one-line cleanup hint after the run completes. */ - let registryStartedCount = 0; - /** Ids of registry-mode services this run started — used by `--stop-services` to clean only its own. */ - const registryStartedIds: string[] = []; - /** id → pid for registry-mode services this run started, captured in the bridge `started` sink. Used by the sync signal-path cleanup. */ - const registryStartedPids = new Map(); - - /** - * Service ids injected as regular dep nodes for this run. The dynamic - * TUI's `TaskStore` only renders rows for tasks it knows about at - * construction time — top-level `initialTasks` alone would hide the - * service rows. We prepend these task objects to `initialTasks` after - * the 2nd-pass `applyServiceRegistry` so the renderer makes rows for - * them and live boot logs land somewhere visible. - */ - let injectedServiceIds: string[] = []; - /** Set form of `injectedServiceIds` used by the executor's onOutput hook to route per-task chunks. */ - const injectedServiceIdSet = new Set(); - /** Service dock store + event bridge (ephemeral mode only in v1). Constructed alongside successful injection. */ - let serviceDockStore: ServiceDockStore | null = null; - let serviceEventBridge: ServiceEventBridge | null = null; - - if (serviceResult.diagnostics.length > 0) { - const isTty = Boolean(process.stdout.isTTY && process.stdin.isTTY); - const decision = options.dryRun - ? "off" - : resolveServicesPolicy({ - cli: options.services, - config: config.run?.services, - isCi: Boolean(isInCi), - isPersistentTarget: persistentTasks.length > 0, - isTty, - target, - }); - - if (decision === "off") { - for (const diagnostic of serviceResult.diagnostics) { - logger.error(diagnostic.message); - } - - // Auto-start exists and would have satisfied every one of these - // deps — it just wasn't reached, because `--dry-run` skips it or - // the TTY-aware default resolved to `off` (CI, a piped stdout, a - // non-interactive shell). Naming the override turns a dead end - // into a one-flag fix; without it a declarative `dependsOn` on a - // service reads as simply unsupported. - const source = describeServicesPolicySource({ cli: options.services, config: config.run?.services }); - const hints: string[] = []; - - if (options.dryRun) { - hints.push("`--dry-run` never starts services; drop it to let vis boot them."); - } else if (source === "default") { - hints.push( - "vis only auto-starts service deps in an interactive terminal by default (this run is CI or non-TTY).", - "Pass `--services=ephemeral` to boot them for this run, or set `run.services` in vis.config.ts.", - ); - } else if (source === "cli") { - hints.push("`--services=off` was passed; use `--services=ephemeral` to boot them for this run."); - } else { - hints.push("`run.services: \"off\"` is set in vis.config.ts; override it with `--services=ephemeral`."); - } - - throw new VisUserError( - [`${serviceResult.diagnostics.length} service dependency error(s) — start the missing services or invoke them directly.`, ...hints].join( - "\n", - ), - ); - } - - const missingIds = serviceResult.diagnostics.map((d) => d.targetId); - const mode = decision; - - // Capture the original (pre-injection) per-service config so the - // bridge's registry entries hold the actual service command — - // `injectServiceTasks` rewrites `task.overrides.command` to the - // `vis service start ` wrapper, which is the wrong thing to - // re-spawn from `#retryRegistry`. - const preInjectionExtracted = mode === "registry" ? extractPreflightTasks(workspaceRoot, missingIds, taskGraph) : undefined; - - // Inject the missing services as regular task graph nodes — - // they'll render as rows in the run TUI with live boot logs, - // execute strictly sequentially (artificial deps chain them), - // and exit 0 the moment readiness passes. The synthesized env - // map below mirrors apply-service-registry's prune-time logic - // without actually pruning the graph. - const visBin = process.argv[1] ?? "vis"; - const injection = injectServiceTasks({ - missingServiceIds: missingIds, - mode, - taskGraph, - visBin, - workspaceRoot, - }); - - if (injection.skipped.length > 0) { - for (const { id, reason } of injection.skipped) { - logger.error(`Cannot auto-start ${id}: ${reason}`); - } - - throw new VisUserError(`${injection.skipped.length} service(s) cannot be auto-started — invoke them directly or add a service config.`); - } - - ephemeralPidFiles.push(...injection.ephemeralPidFiles); - serviceRunDir = injection.runDir; - injectedServiceIds = injection.chain; - - for (const id of injection.chain) { - injectedServiceIdSet.add(id); - } - - if (mode === "registry") { - registryStartedCount = injection.chain.length; - registryStartedIds.push(...injection.chain); - } - - // Stand up the dock + bridge for both modes. Ephemeral surfaces - // boot events through the bootstrap's stdout marker channel and - // running-phase log via fs.watch. Registry surfaces them through - // the wrapper task's `started`/`close` lifecycle events emitted - // by the executor — see the bridge wiring in `onEvent`. - if (injection.chain.length > 0) { - const dock = new ServiceDockStore(injection.chain); - const bridgeEntries = new Map(); - - if (mode === "ephemeral" && injection.runDir) { - for (const id of injection.chain) { - const paths = buildBootstrapPaths(injection.runDir, `${injection.runDir}/bootstrap.mjs`, id); - - bridgeEntries.set(id, { - ephemeral: { - configFile: paths.configFile, - cwd: workspaceRoot, - logFile: paths.logFile, - pidFile: paths.pidFile, - scriptPath: paths.scriptPath, - }, - mode: "ephemeral", - }); - } - } else if (mode === "registry" && preInjectionExtracted) { - for (const svc of preInjectionExtracted.services) { - bridgeEntries.set(svc.id, { - mode: "registry", - registry: { - command: svc.command, - config: svc.config, - cwd: svc.cwd, - env: svc.env, - }, - }); - } - } - - if (bridgeEntries.size > 0) { - const bridge = new ServiceEventBridge({ - indexToId: new Map(), - services: bridgeEntries, - sink: { - crashed: (id, tail) => { - dock.markCrashed(id, tail); - }, - failed: (id, reason, detail) => { - dock.markFailed(id, reason, detail); - }, - log: (id, chunk) => { - dock.appendLog(id, chunk); - }, - ready: (id, info) => { - dock.markReady(id, info); - }, - started: (id, pid) => { - dock.markStarted(id, pid); - - // Capture registry-mode pids for the optional - // --stop-services cleanup. Ephemeral pids are - // already tracked via `ephemeralPidFiles`, so - // skip those to keep the two cleanup paths - // from doubly-killing the same pid. - if (mode === "registry" && pid !== null) { - registryStartedPids.set(id, pid); - } - }, - starting: (id) => { - dock.markStarting(id); - }, - }, - workspaceRoot, - }); - - serviceDockStore = dock; - serviceEventBridge = bridge; - } - } - - // Merge synthesized env on top of the 1st pass's env map. - // 1st-pass entries cover already-running registered services; - // injection covers services we're about to boot. - for (const [taskId, env] of injection.serviceEnvByTaskId) { - const existing = serviceResult.serviceEnvByTaskId.get(taskId) ?? {}; - - serviceResult.serviceEnvByTaskId.set(taskId, { ...existing, ...env }); - } - } - - // applyServiceRegistry was called with `[...initialTasks, ...persistentTasks]` - // so its "is this user-asked-for?" check covers persistent targets like - // `vis run dev`. Its returned `initialTasks` therefore *also* contains - // the persistent ones (minus any satisfied services). Strip them back - // out — `runPersistentTasks` runs them; the regular runner must not. - // Without this filter, `web:serve` is fed to *both* pipelines and the - // TUI renders the row twice. - const persistentIdSet = new Set(persistentTasks.map((task) => task.id)); - - initialTasks = serviceResult.initialTasks.filter((task) => !persistentIdSet.has(task.id)); - taskGraph = serviceResult.taskGraph; - - if (injectedServiceIds.length > 0) { - const serviceTasks = injectedServiceIds.map((id) => taskGraph.tasks[id]).filter((t): t is Task => t !== undefined); - - initialTasks = [...serviceTasks, ...initialTasks]; - } - - // Persistent tasks were folded into the graph above so their service - // deps could be walked. Strip them now so the regular task runner - // doesn't try to execute them — they have their own runner pipeline - // via `runPersistentTasks`. Their dep edges are removed too; the - // service tasks they pointed at become roots and run on their own. - if (persistentTasks.length > 0) { - const persistentIds = new Set(persistentTasks.map((task) => task.id)); - - const filteredTasks: Record = {}; - - for (const [id, task] of Object.entries(taskGraph.tasks)) { - if (!persistentIds.has(id)) { - filteredTasks[id] = task; - } - } - - const filteredDependencies: Record = {}; - - for (const [id, deps] of Object.entries(taskGraph.dependencies)) { - if (persistentIds.has(id)) { - continue; - } - - filteredDependencies[id] = deps.filter((depId) => !persistentIds.has(depId)); - } - - // Recompute roots from the trimmed graph: anything no remaining - // task depends on. Service tasks orphaned by removing their - // dependent persistent task become roots here. - const dependedOn = new Set(); - - for (const deps of Object.values(filteredDependencies)) { - for (const depId of deps) { - dependedOn.add(depId); - } - } - - const filteredRoots = Object.keys(filteredTasks).filter((id) => !dependedOn.has(id)); - - taskGraph = { - dependencies: filteredDependencies, - roots: filteredRoots, - tasks: filteredTasks, - }; - } - - // Service env precedence: the executor merges these on top of - // process.env when running each dependent task. See - // apply-service-registry.ts → ApplyServiceRegistryResult.serviceEnvByTaskId - // for the keying contract (per *dependent*, not per service). - const { serviceEnvByTaskId } = serviceResult; - - if (serviceResult.satisfiedServices.length > 0) { - const names = serviceResult.satisfiedServices.map((s) => s.id).join(", "); - - logger.debug?.(`Auto-attached to running services: ${names}`); - - // Surface attached services in the dock so a second `vis run dev` - // (where the registry already holds api:db, api:redis, …) still - // shows the green pill. Without this, the dock is empty on rerun - // and the user can't tell whether their deps are wired. - if (serviceDockStore) { - for (const entry of serviceResult.satisfiedServices) { - serviceDockStore.registerService(entry.id); - } - } else { - serviceDockStore = new ServiceDockStore(serviceResult.satisfiedServices.map((s) => s.id)); - } - - for (const entry of serviceResult.satisfiedServices) { - const readinessPort = entry.config.readiness?.tcp?.port ?? entry.config.port ?? 0; - const readinessHost = entry.config.readiness?.tcp?.host ?? "127.0.0.1"; - - serviceDockStore.markReady(entry.id, { host: readinessHost, port: readinessPort }); - } - } - - // Flip the task graph for teardown targets (CDK/Pulumi `destroy`, - // `undeploy`, etc.) where dependents must run before the things - // they depend on. Runs after service auto-attach so service - // detection still uses the natural graph; runs before the - // dry-run walk and scheduler so both reflect the reversed order. - // `reverseTaskGraph` recomputes `roots` from the flipped edges, - // so the dry-run printer and `TaskScheduler` need no further - // changes — they already walk topologically over `dependencies`. - if (options.reverse) { - taskGraph = reverseTaskGraph(taskGraph); - logger.debug?.(`Reversed task graph: ${String(taskGraph.roots.length)} new root(s) (originally leaves)`); - } - - // `splitCompound` splits a POSIX shell line, so a target with a custom - // shell runs its command in another language and stays whole. - for (const task of Object.values(taskGraph.tasks)) { - const shellOptions = projectOptions.get(task.target.project)?.[task.target.target]?.options; - - if (task.splitCompound && resolveTargetShell(shellOptions) !== undefined) { - logger.warn(`splitCompound ignored for ${task.id}: it uses a custom shell. Running the command as one unit.`); - task.splitCompound = undefined; - } - } - - // Split `splitCompound` targets into chained segment tasks before the - // dry-run plan and the scheduler read the graph. The runner's own split - // pass then finds nothing left to do. - taskGraph = splitCompoundTasks(taskGraph, { - commandOf: (task) => task.overrides["command"] as string | undefined, - onDiagnostic: (_taskId, message) => { - logger.warn(message); - }, - }); - - if (options.dryRun) { - const taskCount = Object.keys(taskGraph.tasks).length; - const rootCount = taskGraph.roots.length; - - logger.info(`Execution plan (${String(taskCount)} task(s), ${String(rootCount)} root(s)):`); - logger.info(""); - - const visited = new Set(); - const walkPlan = (id: string, depth: number): void => { - if (visited.has(id)) { - return; - } - - visited.add(id); - - for (const dep of taskGraph.dependencies[id] ?? []) { - walkPlan(dep, depth + 1); - } - - const task = taskGraph.tasks[id]; - const indent = " ".repeat(depth + 1); - const segmentNote = task?.segment ? ` (segment ${String(task.segment.index)}/${String(task.segment.count)}: ${String(task.overrides["command"])})` : ""; - // The engine traces every task under the workspace switch, so it is annotated like a per-target trace. - const traced = task?.hashMode === "trace" || config.taskRunner?.autoFingerprint === true; - const annotations = `${task?.cache === false ? " (no-cache)" : ""}${traced ? " (trace)" : ""}${segmentNote}`; - - logger.info(`${indent}${id}${annotations}`); - }; - - for (const root of taskGraph.roots) { - walkPlan(root, 0); - } - - // Visit any tasks not reachable from `roots`. Two cases hit - // this: disconnected graph components, and reversed graphs - // where reverse-roots don't reach the original-leaves through - // outbound `dependencies` (the printer recurses into deps, - // and original-leaves are reverse-dependents — so we'd - // otherwise drop them silently from the plan). - for (const taskId of Object.keys(taskGraph.tasks)) { - walkPlan(taskId, 0); - } - - if (persistentTasks.length > 0) { - logger.info(""); - logger.info(` + ${String(persistentTasks.length)} persistent task(s) (run after graph completes)`); - } - - logger.info(""); - + if (selectProjectsWithTarget(ctx)) { return; } - const startTime = Date.now(); - - // One typed hook registry per run. Plugins register handlers - // via `config.plugins`; the HookableLifeCycle forwards lifecycle - // events from task-runner into the registry so plugin authors - // see `task:before`/`task:after`/`task:cacheHit` etc. without - // needing to understand the lower-level LifeCycleInterface. - const hooks = createVisHooks(); - - // Per-run handler, passed into HookableLifeCycle below. No - // module-level state: concurrent `vis` invocations in the same - // process (programmatic API, Vitest parallel suites) each get - // their own channel. - const onHookError = (hookName: string, error: unknown): void => { - const message = error instanceof Error ? error.message : String(error); - - logger.warn(`Plugin error in ${hookName}: ${message}`); - }; - - await registerPlugins(hooks, config.plugins); - - const profilePath = typeof options.profile === "string" ? options.profile : undefined; - const maybeWriteProfile = async (results: TaskResults): Promise => { - if (!profilePath) { - return; - } - - const summary = generateRunSummary(results, taskGraph, startTime); - - const resolvedProfilePath = profilePath.startsWith("/") ? profilePath : `${workspaceRoot}/${profilePath}`; - - await writeChromeTrace(summary, resolvedProfilePath); - - logger.info(`Profile written to ${profilePath}`); - }; - - // Resolve the cache directory through the shared helper so precedence - // (CLI > vis.config.ts > default) stays consistent with `vis cache` - // and relative --cache-dir values are normalized against the - // workspace root. Other fields keep their existing spread semantics - // to avoid changing override precedence for parallel/dryRun/etc. - const configTaskRunner = (config.taskRunner ?? {}) as TaskRunnerOptions & { cacheDirectory?: string }; - // Anchor the default cache path to the *main* worktree root when this - // workspace is a linked git worktree, so sibling agents share one cache - // instead of rebuilding the same hash N times. Explicit paths - // (--cache-dir, vis.config.ts, VIS_CACHE_DIRECTORY) win unchanged. - const baseCacheDirectory = resolveSharedCacheDirectory(workspaceRoot, options.cacheDir, configTaskRunner.cacheDirectory, config.sharedWorktreeCache); - // Branch-scope the cache dir when configured so main/feature - // branches stop overwriting each other's entries. - const resolvedCacheDirectory = applyBranchScope(baseCacheDirectory, workspaceRoot, config.branchScopedCache); - - // Concurrency resolution order: --parallel CLI flag > VIS_RUN_CONCURRENCY_LIMIT - // env > built-in default of 3. Matches vite-task's - // VP_RUN_CONCURRENCY_LIMIT semantics — lets CI runners set a - // global limit via env without passing the flag on every call. - // An invalid env value surfaces as a warn so misconfigurations - // aren't silently ignored. - const parallelEnvParsed = parseEnvConcurrency(process.env["VIS_RUN_CONCURRENCY_LIMIT"]); - - if (parallelEnvParsed && "invalid" in parallelEnvParsed) { - logger.warn(`VIS_RUN_CONCURRENCY_LIMIT=${parallelEnvParsed.invalid} is not a positive number; falling back to default concurrency.`); - } - - const parallelFromEnv = parallelEnvParsed && "value" in parallelEnvParsed ? parallelEnvParsed.value : undefined; - const resolvedParallel = options.parallel ?? parallelFromEnv ?? 3; - - // CLI flag wins over the workspace config; per-target opt-in/out - // is applied later in the executor against this resolved baseline. - const resolvedStrictEnv = options.strictEnv ?? config.strictEnv ?? false; - - // One warning per task per run for the "cacheable task, zero input files" - // diagnostic (see `TaskRunnerOptions.onDiagnostic`) — a config bug usually - // trips every affected task, so dedupe to keep the output readable. - const warnedEmptyInputs = new Set(); - - const runnerOptions: TaskRunnerOptions = { - dryRun: options.dryRun ?? false, - parallel: resolvedParallel, - skipNxCache: !options.cache, - summarize: options.summarize ?? false, - ...configTaskRunner, - // Applied after the config spread so the user's `--cache-dir` flag - // wins over a config value and relative paths are normalized - // against `workspaceRoot` via `resolveCacheDirectory()`. - cacheDirectory: resolvedCacheDirectory, - // Forward the vis-owned data directory so run summaries land in - // `/.vis/` rather than the task-runner default - // `/.task-runner/`. The config-supplied value - // (if any) is honoured because the spread above can carry it - // through; the explicit assignment here guarantees vis-driven - // runs always co-locate their state under `.vis/`. - dataDirectory: configTaskRunner.dataDirectory ?? getVisWorkspaceDataDir(workspaceRoot), - // `namedInputs` lives at the TOP LEVEL of the vis config, not under - // `taskRunner`, so the `...configTaskRunner` spread above does not carry - // it. Forward it explicitly: without it the task-runner's hasher gets an - // empty named-inputs map, so a task whose `inputs` reference a named - // input (`production`, `default`, …) resolves those names as LITERAL, - // non-existent directories — contributing zero files to the cache key. - // The task then false-cache-hits forever and never re-runs on a source - // change. (Tasks with no `inputs` fall back to `{projectRoot}/**/*`, - // which is why `lint:types` worked while `build` did not.) - namedInputs: config.namedInputs, - // Surface the hasher's "cacheable task resolved 0 input files" diagnostic - // through the logger — the early-warning for a dropped/misconfigured input - // that would otherwise silently false-cache-hit forever. - onDiagnostic: (taskId, message) => { - if (warnedEmptyInputs.has(taskId)) { - return; - } - - warnedEmptyInputs.add(taskId); - logger.warn(message); - }, - // Bridge the typed `task:fingerprint` hook into task-runner's - // `onFingerprint` callback. Placed after the config spread so - // the plugin pipeline always runs — a config-supplied - // `onFingerprint` would silently disable every plugin's - // fingerprint contributions. - // - // Errors propagate intentionally: a buggy plugin must fail the - // task before any cache lookup runs (per the hook's documented - // contract — see `VisHooks["task:fingerprint"]`). - onFingerprint: async (task, contributor) => { - await hooks.callHook("task:fingerprint", task, contributor); - }, - }; - - // Child-environment policy per task: the workspace `taskRunner.env`, unless - // the target sets its own `options.env`. The executor, the persistent launch - // and the traced run build the child env with it, and the task hasher keys on - // the names it lets through. - const childEnvPolicyFor = (task: Task): Required => effectiveChildEnvPolicy(runnerOptions.env, getTaskOptions(task)?.env); - - // Built once per run. Every spawn of a task (executor, persistent launch and - // traced run) reads its child environment from this context. - const spawnContext = createTaskSpawnContext({ - affectedFiles, - childEnvPolicy: childEnvPolicyFor, - initCwd: invocationCwd, - serviceEnvByTaskId, - taskSlots: resolvedParallel, - workspaceRoot, - }); - - // The command a spawn runs, and the strict-env failure for it. The executor - // and the traced run both call these, so the two read the same command and - // the same child environment. - const resolveCommand = (task: Task): string | undefined => resolveTaskCommand(task, affectedFiles, currentOs); - - const strictEnvFailure = (task: Task, command: string, execOptions: { cwd?: string }): string | undefined => { - // A per-target setting wins over the workspace flag, so one target can opt out (or in). - const strictEnvActive = getTaskOptions(task)?.strictEnv ?? resolvedStrictEnv; - - if (!strictEnvActive) { - return undefined; - } - - const spawn = resolveTaskSpawn(spawnContext, task, execOptions); - const childPolicy = spawnContext.childEnvPolicy(task); - const violation = checkStrictEnv({ - command, - // A variable the clean child cannot see is unset for the check, so `$VAR` fails the task instead of expanding to "". - processEnv: childPolicy.mode === "clean" ? pickChildParentEnv(process.env, childPolicy) : process.env, - taskEnv: spawn.env, - taskId: task.id, - }); - - return violation ? formatStrictEnvError(violation) : undefined; - }; - - // The traced run's pre-spawn check: the same strict-env failure the executor reports. - const preTraceCheck = (task: Task, spawnOptions: { cwd: string }): string | undefined => { - const command = resolveCommand(task); - - return command === undefined ? undefined : strictEnvFailure(task, command, spawnOptions); - }; - - // Resolve the remote-cache config in three layers (CLI > vis.config.ts - // > Turbo env-var compat). Fill missing HTTP fields from TURBO_API / - // TURBO_TOKEN / TURBO_TEAM so a workspace migrating from Turbo keeps - // working without rewriting CI secrets. Rejects malformed CLI values - // up front so a typo in `--cache-mode=eread` doesn't silently fall - // through to the runner and surface as "no cache hits". - const envCompatRemoteCache = resolveTurboEnvCompat(configTaskRunner.remoteCache); - - if (envCompatRemoteCache) { - const cliMode = parseCacheMode(options.cacheMode); - const cliBackend = parseCacheBackend(options.cacheBackend); - - runnerOptions.remoteCache = { - ...envCompatRemoteCache, - ...(cliMode ? { mode: cliMode } : {}), - ...(cliBackend ? { backend: cliBackend } : {}), - }; - - // Config can only carry serializable attestation knobs - // (`requireOnDownload`, `expectedIdentity`); the actual keyless - // sign/verify callbacks can't live in vis.config.ts. When the - // user opted in, swap the declarative stub for Sigstore-backed - // hooks here. - if (runnerOptions.remoteCache.attestation) { - const { expectedIdentity, requireOnDownload } = runnerOptions.remoteCache.attestation; - - // Verifying a keyless bundle without pinning the signer - // proves integrity, not authenticity — that is what the HMAC - // `signing` block already does. Accept any of the three - // public identity forms; refuse a half-configured setup - // loudly rather than ship a false sense of provenance. - const identityConfigured - = expectedIdentity !== undefined - && (("github" in expectedIdentity - && typeof expectedIdentity.github?.ref === "string" - && typeof expectedIdentity.github.repo === "string" - && typeof expectedIdentity.github.workflow === "string") - || ("oidcIssuer" in expectedIdentity - && typeof expectedIdentity.oidcIssuer === "string" - && (typeof (expectedIdentity as { san?: unknown }).san === "string" - || typeof (expectedIdentity as { sanRegex?: unknown }).sanRegex === "string"))); - - if (!identityConfigured) { - throw new Error( - "[vis run] remoteCache.attestation requires a pinned keyless signer via `expectedIdentity`. Use one of:\n • { github: { repo, workflow, ref } } (GitHub Actions — recommended)\n • { oidcIssuer, san } (literal identity; vis regex-escapes + anchors it)\n • { oidcIssuer, sanRegex } (advanced: raw regex, you own anchoring)\nWithout it, verification is integrity-only — use `remoteCache.signing` (HMAC) for that instead.", - ); - } - - // Only the raw-regex form carries the unanchored-match - // hazard: sigstore matches the SAN with `String.match`, so a - // substring-matched pattern lets a longer attacker SAN that - // merely *contains* the expected value pass. The `github` - // and literal `san` forms are escaped and `^…$`-anchored by - // `normalizeExpectedIdentity`, so they're safe by default. - if ("sanRegex" in expectedIdentity && (!expectedIdentity.sanRegex.startsWith("^") || !expectedIdentity.sanRegex.endsWith("$"))) { - logger.warn( - "[vis run] remoteCache.attestation.expectedIdentity.sanRegex is not anchored (^…$). sigstore matches it as a regex; an unanchored value is substring-matched and weakens the identity pin. Prefer the literal `san` form unless you need a pattern.", - ); - } - - const { installCommandFor, isSigstoreInstalled } = await import("../../security/sigstore/loader"); - - // Surface a missing optional peer dep at startup rather than - // mid-run: warn (don't fail) so a misconfigured consumer - // still runs, just without provenance, with a copy-paste fix. - if (!isSigstoreInstalled()) { - logger.warn( - `[vis run] remoteCache.attestation is configured but the optional \`sigstore\` package is not installed. Cache uploads will be unsigned and signed entries can't be verified until you install it:\n ${installCommandFor(workspaceRoot)}`, - ); - } - - const { buildCacheAttestationHooks } = await import("../../security/sigstore/cache-attestation"); - - runnerOptions.remoteCache.attestation = buildCacheAttestationHooks({ - expectedIdentity, - onReject: (hash, reason) => { - logger.warn(`[vis run] remote cache entry ${hash.slice(0, 12)} rejected: attestation ${reason}. Treating as a cache miss.`); - }, - onVerifyFailure: (message) => { - logger.warn(`[vis run] attestation verification failed: ${message}`); - }, - requireOnDownload, - workspaceRoot, - }); - } - } else if (options.cacheMode || options.cacheBackend) { - logger.warn("[vis run] --cache-mode and --cache-backend require a `remoteCache` block in vis.config.ts (or TURBO_API env); ignoring."); + if (buildInitialTasks(ctx)) { + return; } - const isTTY = process.stdout.isTTY && !isInCi; - // Opt-in "quiet on success": suppress successful/cached task output and, - // in the interactive TUI, auto-close a few seconds after a clean run. - // Defaults to off; `--output-style` and `tui.autoExit` still override. - const quietOnSuccess = visConfig?.run?.quietOnSuccess === true; - // Auto-exit the interactive TUI after a clean run when quietOnSuccess is - // on (3s countdown). An explicit `tui.autoExit` wins. A failed run never - // auto-closes — VisTaskRunnerApp only arms the countdown when failed === 0. - const autoExitConfig = config.tui?.autoExit ?? (quietOnSuccess ? 3 : false); - // Include persistent tasks in the lifecycle's view so the header - // ("Running targets serve for 1 project") and summary count them - // even when the regular task graph is empty (e.g. `vis run serve` - // for a project whose `serve` target has `preset: "server"`). - const lifecycleOptions = { - args: { parallel: runnerOptions.parallel, targets: [target] }, - autoExit: autoExitConfig, - projectNames: projectsWithTarget, - // A `splitCompound` target runs as its segments, so list those in its - // place; the unsplit parent never completes and would show as skipped. - tasks: [...initialTasks, ...persistentTasks].flatMap((task) => { - const segments = Object.values(taskGraph.tasks).filter((candidate) => candidate.segment !== undefined && candidate.id.startsWith(`${task.id}#`)); - - return segments.length > 0 ? segments : [task]; - }), - // The table rows above only cover the directly-requested tasks. The - // executor also runs every `dependsOn` dependency in `taskGraph`, and - // those count toward the succeeded/failed/cached tallies. Pass the - // full graph size so the "N total" label shares that scope instead of - // under-reporting at the row count. - totalTaskCount: Object.keys(taskGraph.tasks).length + persistentTasks.length, - }; - - const retryBudgetLimit = typeof options.retryBudget === "number" ? options.retryBudget : undefined; - const sharedRetryBudget = retryBudgetLimit === undefined ? undefined : createRetryBudget(retryBudgetLimit); - - const hookLifeCycle = new HookableLifeCycle(hooks, onHookError); - const failureLogLifeCycle = new FailureLogLifeCycle(workspaceRoot); + buildRunGraph(ctx); + await runPreflightChecks(ctx); - // Explicit `--output-style` wins; absent the flag, quiet only when - // run.quietOnSuccess is on (computed above, defaults to off). - const outputStyle = resolveOutputStyle(options.outputStyle, visConfig?.run?.quietOnSuccess); + const serviceResult = await attachRegisteredServices(ctx); - // Fire service:attach for each registered service we attached to. - // Computed earlier in `applyServiceRegistry`; the hook fires here - // because `hooks` only exists post-`registerPlugins`. `taskIds` is - // the set of in-graph dependents that consume the service's env — - // empty when the service was attached but no dependent kept it. - // - // service:attach is an observation hook (see VisHooks docstring), - // so a buggy plugin warns through `onHookError` rather than aborting - // the run. Matches how the standalone `vis service` commands fire - // service:start/service:stop in `commands/service/handler.ts`. - if (serviceResult.satisfiedServices.length > 0) { - for (const entry of serviceResult.satisfiedServices) { - const taskIds = serviceResult.serviceDependentsByServiceId.get(entry.id) ?? []; + injectMissingServices(ctx, serviceResult); - try { - await hooks.callHook("service:attach", entry, taskIds); - } catch (error) { - onHookError("service:attach", error); - } - } - } + // Arm as soon as services may have booted: a failure in graph + // finalization, shaping, the runtime hooks or the modes must still tear + // them down. + const cleanup = armRunCleanup(ctx); - await hooks.callHook("run:before", { tasks: initialTasks, workspaceRoot }); + try { + finalizeServiceGraph(ctx, serviceResult); + shapeTaskGraph(ctx); - /** - * SIGTERM the ephemeral service group + drop the scratch dir exactly - * once. Run from the run-block's `finally`, AND from a Ctrl+C/SIGTERM - * listener so children don't outlive the run when the dynamic TUI's - * own `process.exit(1)` short-circuits the normal exit path. - */ - const stopServicesOnExit = options.stopServices; - let cleanupRan = false; - const runCleanupOnce = (): void => { - if (cleanupRan) { + if (printDryRunPlan(ctx)) { return; } - cleanupRan = true; - - if (serviceEventBridge) { - serviceEventBridge.dispose().catch(() => {}); - } - - // When --stop-services is set, also kill the registry-mode services - // this run started. Their pids are captured by the bridge `started` - // sink. Registry entries point at dead pids after this; `vis - // service list` / `pruneDead` cleans them up later. - const extraPids = stopServicesOnExit ? [...registryStartedPids.values()] : undefined; - - cleanupEphemeralServices({ extraPids, pidFiles: ephemeralPidFiles, runDir: serviceRunDir }); - }; - const onCleanupSignal = (): void => { - runCleanupOnce(); - }; - const hasCleanupWork = ephemeralPidFiles.length > 0 || serviceRunDir !== undefined || (stopServicesOnExit && registryStartedIds.length > 0); - - if (hasCleanupWork) { - process.on("SIGINT", onCleanupSignal); - process.on("SIGTERM", onCleanupSignal); - } - - try { - if (isTTY) { - const stdinRegistry = new Map(); - const dynamic = createDynamicOutputRenderer({ - ...lifecycleOptions, - onRetryService: serviceEventBridge ? (id) => serviceEventBridge.retry(id) : undefined, - outputStyle, - serviceDockStore, - stdinRegistry, - }); - const { lifeCycle: uiLifeCycle, store } = dynamic; - // Fan lifecycle events out to both the UI renderer and the - // plugin hook layer so subscribers see the same events - // without adding another renderer. - const lifeCycle = new CompositeLifeCycle([uiLifeCycle, hookLifeCycle, failureLogLifeCycle]); - const mutexPool: MutexPool = new Map(); - const taskExecutor = createConcurrentExecutor({ - hooks, - lifeCycle, - mutexPool, - onOutput: (taskId, text) => { - if (serviceEventBridge && injectedServiceIdSet.has(taskId)) { - serviceEventBridge.onTaskOutput(taskId, text); - } else { - store.addOutput(taskId, text); - } - }, - onOutputReplace: (taskId, fullContent) => { - store.setOutput(taskId, fullContent); - }, - resolveCommand, - retryBudget: sharedRetryBudget, - serviceEventBridge: serviceEventBridge ?? undefined, - spawnContext, - stdinRegistry, - strictEnvFailure, - workspaceRoot, - }); - - let loopAction: "quit" | "rerun" | "retry" = "rerun"; - let retryTaskId: string | null = null; - - let lastResults: TaskResults = new Map(); + const runtime = await prepareRunRuntime(ctx, serviceResult); - while (loopAction !== "quit") { - if (loopAction === "rerun") { - lastResults = await defaultTaskRunner(initialTasks, runnerOptions, { - childEnvPolicy: childEnvPolicyFor, - lifeCycle, - preTraceCheck, - projectGraph, - resolveChildEnv: (task, spawnOptions) => resolveTaskSpawn(spawnContext, task, spawnOptions), - resolveCommand, - taskExecutor, - taskGraph, - workspaceRoot, - }); - - // Persistent tasks (servers, watchers) start AFTER the - // regular graph completes but BEFORE the loop awaits - // user input — that's the only way the dynamic TUI - // can show their spinners and stream their output. - // The abortSignal kills them when the user signals - // rerun/retry/quit so the outer loop can react. - if (persistentTasks.length > 0 && !options.failFast) { - const abortSignal = new Promise((resolve) => { - const unsubscribe = store.subscribe(() => { - const s = store.getSnapshot(); - - if (s.rerunRequested || s.retryTaskId) { - unsubscribe(); - resolve(); - } - }); - - dynamic.renderIsDone - .then(() => { - unsubscribe(); - resolve(); - - return undefined; - }) - .catch(() => { - unsubscribe(); - resolve(); - }); - }); - - await runPersistentTasks(persistentTasks, spawnContext, { - abortSignal, - lifeCycle, - stdinRegistry, - store, - }); - } - } else if (loopAction === "retry" && retryTaskId) { - // A split target retries by segment id, which only the graph knows. - const task = taskGraph.tasks[retryTaskId] ?? initialTasks.find((t) => t.id === retryTaskId); - // Same command the first attempt ran, so a retry keeps its forwarded arguments and shell. - const command = task ? resolveTaskCommand(task, spawnContext.affectedFiles, currentOs) : undefined; - - if (task && command) { - lifeCycle.startTasks?.([task]); - - const retryTermBuf = new TerminalBuffer(MAX_OUTPUT_BYTES); - - // Same cwd and child environment as the first attempt. - const retrySpawn = resolveRetrySpawn(spawnContext, task); - - const retryResult = await runConcurrently( - [ - { - command, - cwd: retrySpawn.cwd, - env: retrySpawn.env, - inheritEnv: retrySpawn.inheritEnv, - name: task.id, - ptySize: { cols: process.stdout.columns ?? 80, rows: process.stdout.rows ?? 24 }, - stdin: "pty", - }, - ], - { - onEvent: (event: ProcessEvent) => { - if (event.kind === "started" && event.write) { - stdinRegistry.set(task.id, { kill: event.kill, resize: event.resize, write: event.write }); - } - - if ((event.kind === "stdout" || event.kind === "stderr") && event.text) { - retryTermBuf.write(event.text); - store.setOutput(task.id, retryTermBuf.toString()); - } - - if (event.kind === "close") { - stdinRegistry.delete(task.id); - } - }, - }, - ); - - const closeEvent = retryResult.closeEvents[0]; - - lifeCycle.endTasks?.([ - { - code: closeEvent?.exitCode ?? 1, - status: closeEvent?.exitCode === 0 ? "success" : "failure", - task, - terminalOutput: store.getSnapshot().outputs.get(task.id), - }, - ]); - } else if (task) { - lifeCycle.endTasks?.([ - { - code: 1, - status: "failure", - task, - terminalOutput: `No command configured for ${task.id}`, - }, - ]); - } - - retryTaskId = null; - - // Mark done after retry so user can rerun/retry again - store.markDone(); - } - - loopAction = await new Promise<"quit" | "rerun" | "retry">((resolve) => { - const unsubscribe = store.subscribe(() => { - const s = store.getSnapshot(); - - if (s.rerunRequested) { - store.acknowledgeRerun(); - unsubscribe(); - resolve("rerun"); - } - - if (s.retryTaskId) { - retryTaskId = store.acknowledgeRetry(); - unsubscribe(); - resolve("retry"); - } - }); - - dynamic.renderIsDone - .then(() => { - unsubscribe(); - resolve("quit"); - - return undefined; - }) - .catch(() => { - unsubscribe(); - resolve("quit"); - }); - }); - } - - await dynamic.renderIsDone; - await hooks.callHook("run:after", lastResults); - await maybeWriteProfile(lastResults); - - if (registryStartedCount > 0) { - logger.info(""); - - if (stopServicesOnExit) { - logger.info(`${String(registryStartedCount)} service(s) stopped (--stop-services).`); - } else { - logger.info(`${String(registryStartedCount)} service(s) started in the background. Run \`vis service stop --all\` to clean up.`); - } - } - } else { - const mutexPool: MutexPool = new Map(); - const logModeOption = typeof options.log === "string" ? options.log.toLowerCase() : ""; - const logMode: LogMode | undefined - = logModeOption === "labeled" || logModeOption === "grouped" || logModeOption === "interleaved" ? logModeOption : undefined; - const logReporter = logMode ? createLogReporter(logMode) : undefined; - // Composite so plugin hooks see every task boundary event in - // addition to the CI-style static renderer. Build the - // lifecycle first so the executor can forward streaming - // stdout/stderr chunks into it. - const lifeCycle = new CompositeLifeCycle([ - new StaticOutputLifeCycle({ ...lifecycleOptions, ciGrouping: visConfig?.run?.ciGrouping ?? "auto", logReporter, outputStyle }), - hookLifeCycle, - failureLogLifeCycle, - ]); - - const taskExecutor = createConcurrentExecutor({ - hooks, - lifeCycle, - mutexPool, - resolveCommand, - retryBudget: sharedRetryBudget, - serviceEventBridge: serviceEventBridge ?? undefined, - spawnContext, - strictEnvFailure, - workspaceRoot, - }); - - const runOnce = async (): Promise<{ hasFailure: boolean; results: TaskResults; runHistory: Awaited> }> => { - const runStart = Date.now(); - - const results = await defaultTaskRunner(initialTasks, runnerOptions, { - childEnvPolicy: childEnvPolicyFor, - lifeCycle, - preTraceCheck, - projectGraph, - resolveChildEnv: (task, spawnOptions) => resolveTaskSpawn(spawnContext, task, spawnOptions), - resolveCommand, - taskExecutor, - taskGraph, - workspaceRoot, - }); - - const durationMs = Date.now() - runStart; - - if (options.summarize) { - const summary = generateRunSummary(results, taskGraph, startTime); - - await writeRunSummary(summary, workspaceRoot, { dataDirectory: getVisWorkspaceDataDir(workspaceRoot) }); - } - - let hasFailure = false; - - for (const [, result] of results) { - if (result.status === "failure") { - hasFailure = true; - } - } - - const timingLine = formatTimingSummary(results, durationMs); - - // Load historical summaries once here and share with - // the flakiness analyzer below — both consumers need - // the same files and duplicate reads dominate when - // the runs/ dir has accumulated hundreds of entries. - const runHistory = loadRunSummaries(workspaceRoot); - const durationComparison = compareDuration(workspaceRoot, durationMs, runHistory); - - logger.info(""); - logger.info(` ${timingLine}${durationComparison ? ` ${durationComparison}` : ""}`); - - return { hasFailure, results, runHistory }; - }; - - const firstRun = await runOnce(); - - await hooks.callHook("run:after", firstRun.results); - await maybeWriteProfile(firstRun.results); - - const { hasFailure } = firstRun; - - if (options.watch) { - const absoluteRoots = projectsWithTarget - .map((name) => { - const project = workspace.projects[name]; - const root = project?.root; - - if (!root) { - return undefined; - } - - return root.startsWith("/") ? root : `${workspaceRoot}/${root}`; - }) - .filter((p): p is string => p !== undefined); - - // Snapshot the post-partition task set so `a` (clear filter) - // can return to it after `p` narrowed the run. - const baseTasks = initialTasks; - let projectFilter: string | undefined; - - const applyFilter = (filter: string | undefined): number => { - const result = applyProjectFilter(baseTasks, filter); - - projectFilter = result.filter; - initialTasks = result.tasks; - - return result.tasks.length; - }; - - let running = false; - let lastResults = firstRun.results; - - // Builds a fresh watcher after each run so the watch set - // reflects the files the latest run actually touched. - // Falls back to project roots on the first invocation - // (no prior results) or when no task carries hashDetails. - const buildHandle = (): { handle: ReturnType; mode: "tracked" | "roots" } => { - const targets = collectTrackedWatchTargets(lastResults, workspaceRoot); - - if (targets.directories.length > 0 && targets.files.size > 0) { - const filter = createTrackedFileFilter(targets.files, workspaceRoot, targets.directories); - - return { - handle: startWatcher({ - filter, - onChange: onChangeHandler, - paths: targets.directories, - }), - mode: "tracked", - }; - } - - return { - handle: startWatcher({ onChange: onChangeHandler, paths: absoluteRoots }), - mode: "roots", - }; - }; - - let currentHandle: ReturnType | undefined; - - const onChangeHandler = async (paths: string[]): Promise => { - if (running) { - return; - } - - running = true; - - try { - logger.info(`Change detected in ${paths.length} file(s), rerunning…`); - - const nextRun = await runOnce(); - - lastResults = nextRun.results; - - // Rebuild the watcher so the next event only - // fires for files the newest run still cares - // about. Cheap — under 100ms on typical graphs. - currentHandle?.close(); - - const rebuilt = buildHandle(); - - currentHandle = rebuilt.handle; - } finally { - running = false; - } - }; - - const initial = buildHandle(); - - currentHandle = initial.handle; - - const scope - = initial.mode === "tracked" - ? `Watching ${String(collectTrackedWatchTargets(lastResults, workspaceRoot).files.size)} tracked file(s)` - : `Watching ${String(absoluteRoots.length)} project root(s)`; - - logger.info(`${scope} — edit a file to rerun, press h for keybinds, q to quit.`); - - const triggerRerun = async (): Promise => { - if (running) { - return; - } - - running = true; - - try { - if (initialTasks.length === 0) { - logger.info("No tasks match the active filter — press a to clear it."); - - return; - } - - const nextRun = await runOnce(); - - lastResults = nextRun.results; - currentHandle?.close(); - - const rebuilt = buildHandle(); - - currentHandle = rebuilt.handle; - } finally { - running = false; - } - }; - - await new Promise((resolve) => { - let exited = false; - // Forward declarations so `exit()` can reference both the - // SIGINT listener and the keybind handle without hitting - // a TDZ if a future refactor calls exit() synchronously. - let keybinds: KeybindHandle | undefined; - const onSigint = (): void => { - exit(); - }; - - const exit = (): void => { - if (exited) { - return; - } - - exited = true; - keybinds?.close(); - process.off("SIGINT", onSigint); - currentHandle?.close(); - resolve(); - }; - - process.on("SIGINT", onSigint); - - keybinds = installKeybinds({ - handlers: { - onClearFilter: async () => { - const total = applyFilter(undefined); - - logger.info(`Filter cleared — running ${String(total)} task(s).`); - await triggerRerun(); - }, - onFilter: async (pattern) => { - // Capture before mutating — applyFilter() overwrites - // projectFilter, so we'd lose the rollback target - // otherwise. - const previousFilter = projectFilter; - const matching = applyFilter(pattern); - - if (matching === 0) { - logger.info(`Filter "${pattern}" matched no projects — keeping previous filter.`); - applyFilter(previousFilter); - - return; - } - - logger.info(`Filter "${pattern}" matched ${String(matching)} task(s).`); - await triggerRerun(); - }, - onHelp: () => { - writeHelp(process.stdout); - }, - onQuit: () => { - exit(); - }, - onRerun: triggerRerun, - }, - }); - }); - - return; - } - - // Print historical flakiness regardless of this run's outcome — - // a clean run that masked a flaky task via retries (or one that - // hasn't tripped the flake yet today) shouldn't hide history - // that would help the user investigate. Suppressed with - // `--no-flaky`. Reuses the history already loaded for the - // timing comparison above — the runs/ directory hasn't - // changed since this turn of the loop started. - if (options.flaky) { - const flakyStats = analyzeFlakiness(workspaceRoot, { minRuns: 2 }, firstRun.runHistory); - - if (flakyStats.length > 0) { - logger.info(""); - logger.info("Flaky tasks (based on historical runs):"); - logger.info(""); - - for (const line of formatFlakinessTable(flakyStats)) { - logger.info(` ${line}`); - } - - logger.info(""); - } - } - - // `--fail-on-retry` upgrades any retried-but-passed task into a - // run failure. Lets CI surface flakes that retries would - // otherwise mask — useful as a periodic "nightly" check that - // disables the safety net the day-to-day workflow keeps on. - const retriedTaskIds: string[] = []; - - for (const [taskId, result] of firstRun.results) { - if (result.retryAttempts && result.retryAttempts > 0) { - retriedTaskIds.push(taskId); - } - } - - const failOnRetry = options.failOnRetry && retriedTaskIds.length > 0; - - if (failOnRetry) { - // Cap the rendered list so a wide flake (every task retried - // once) doesn't produce a multi-screen warn line that buries - // surrounding context in CI logs. - const MAX_LISTED = 5; - const listedIds = retriedTaskIds.slice(0, MAX_LISTED); - const remaining = retriedTaskIds.length - listedIds.length; - const idsRendered = remaining > 0 ? `${listedIds.join(", ")}, and ${String(remaining)} more` : listedIds.join(", "); - - logger.warn(""); - logger.warn(`--fail-on-retry: ${String(retriedTaskIds.length)} task(s) succeeded only after retry: ${idsRendered}`); - } - - if (hasFailure || failOnRetry) { - const headline = failOnRetry && !hasFailure ? "Some tasks succeeded only after retry (--fail-on-retry)." : "Some tasks failed."; - - // Tail the captured terminal output of every failed task into - // the thrown error so callers (especially programmatic ones - // like vitest runs that mute the logger) see *why* a task - // failed, not just *that* one did. Cap each tail so a runaway - // log doesn't drown the stack trace. - const TAIL_BYTES = 2000; - const failureDetails: string[] = []; - - for (const [taskId, result] of firstRun.results) { - if (result.status !== "failure") { - continue; - } - - const output = result.terminalOutput ?? ""; - const tail = output.length > TAIL_BYTES ? `…${output.slice(-TAIL_BYTES)}` : output; - const code = result.code ?? "?"; - - failureDetails.push( - ` ${taskId} (exit ${String(code)}):\n${tail - .split("\n") - .map((l) => ` ${l}`) - .join("\n")}`, - ); - } - - // A failed task is the run reporting a result, not vis - // malfunctioning — a stack here would bury the summary block - // that actually names the failures. - throw new VisUserError(failureDetails.length > 0 ? `${headline}\n${failureDetails.join("\n")}` : headline); - } - - if (persistentTasks.length > 0 && !options.failFast) { - await runPersistentTasks(persistentTasks, spawnContext, undefined); - } - - if (registryStartedCount > 0) { - logger.info(""); - - if (stopServicesOnExit) { - logger.info(`${String(registryStartedCount)} service(s) stopped (--stop-services).`); - } else { - logger.info(`${String(registryStartedCount)} service(s) started in the background. Run \`vis service stop --all\` to clean up.`); - } - } - } + await (runtime.isTTY ? runInteractiveMode(ctx, runtime) : runStaticMode(ctx, runtime)); } finally { - runCleanupOnce(); - - if (hasCleanupWork) { - process.off("SIGINT", onCleanupSignal); - process.off("SIGTERM", onCleanupSignal); - } - - // Clean exit path: also delete the registry entries for services - // we just stopped so `vis service list` doesn't show stale rows. - // The signal-handler path skips this — sync FS work in a SIGINT - // handler is risky, and `pruneDead` reconciles dead entries on - // demand anyway. - if (stopServicesOnExit && registryStartedIds.length > 0) { - await Promise.all( - registryStartedIds.map(async (id) => { - try { - await deleteEntry(workspaceRoot, id); - } catch { - // Best-effort — entry may already be gone. - } - }), - ); - } + await teardownRun(ctx, cleanup); } }; export default execute as CommandExecute; - -export type { RetryBudget } from "./handler-parts/executor"; -export { createRetryBudget } from "./handler-parts/executor"; diff --git a/packages/tooling/vis/src/commands/run/service-preflight.ts b/packages/tooling/vis/src/commands/run/service-preflight.ts index 98705b0f..3a3d6964 100644 --- a/packages/tooling/vis/src/commands/run/service-preflight.ts +++ b/packages/tooling/vis/src/commands/run/service-preflight.ts @@ -3,12 +3,11 @@ import { tmpdir } from "node:os"; import { writeFileSync } from "@visulima/fs"; import { join } from "@visulima/path"; -import type { Task, TaskGraph } from "@visulima/task-runner"; +import type { TaskGraph } from "@visulima/task-runner"; import { buildEnhancedPath } from "@visulima/task-runner"; import type { ServiceConfig } from "../../services/types"; -import type { VisTargetOptions } from "../../task/target-options"; -import { loadEnvFile, resolveTaskCwd } from "../../task/target-options"; +import { getVisOptions, loadEnvFile, resolveTaskCwd } from "../../task/target-options"; /** Lifecycle mode for an auto-started service. */ export type PreflightMode = "ephemeral" | "registry"; @@ -29,12 +28,6 @@ interface PreflightTaskExtraction { skipped: { id: string; reason: string }[]; } -const getVisOptions = (task: Task): VisTargetOptions | undefined => { - const options = task.overrides["visOptions"]; - - return options && typeof options === "object" ? options : undefined; -}; - /** * Walk `taskGraph.tasks[id]` for each missing service id and produce the * spawnable shape the orchestrator needs. Tasks without a `service:` diff --git a/packages/tooling/vis/src/config/types-parts/project.ts b/packages/tooling/vis/src/config/types-parts/project.ts index 056336b9..e2430150 100644 --- a/packages/tooling/vis/src/config/types-parts/project.ts +++ b/packages/tooling/vis/src/config/types-parts/project.ts @@ -1,13 +1,30 @@ import type { ProjectConfiguration, TargetConfiguration } from "@visulima/task-runner"; import type { VisTargetConfiguration } from "../../task/target-options"; -import type { ScopedTasksMatch } from "../types"; import type { OwnersEntry } from "./codeowners"; +/** + * A predicate used by the `scopedTasks` config block. + * All listed constraints must match for the block to apply. + */ +export interface ScopedTasksMatch { + /** Match on primary language. */ + language?: string | string[]; + /** Match on project layer. */ + layer?: ProjectJson["layer"] | ProjectJson["layer"][]; + /** Match on project type. */ + projectType?: "application" | "library" | "service" | "tool"; + /** Match on project stack. */ + stack?: ProjectJson["stack"] | ProjectJson["stack"][]; + /** Match projects tagged with any of these tags. */ + tags?: string[]; +} + /** * One user-declared customTypes entry. See `policy.customTypes.extraTypes` * for the full contract — this is just the row shape. */ + export interface ExtraCustomType { /** * Required when `strategy === "string"`. The dep-cluster key the bare diff --git a/packages/tooling/vis/src/config/types.ts b/packages/tooling/vis/src/config/types.ts index f10c8422..9c4265e5 100644 --- a/packages/tooling/vis/src/config/types.ts +++ b/packages/tooling/vis/src/config/types.ts @@ -11,24 +11,7 @@ import type { VisPlugin } from "../util/hooks"; import type { CodeownersConfig } from "./types-parts/codeowners"; import type { LintFmtAdapterOverride } from "./types-parts/lint"; import type { PolicyName } from "./types-parts/policies"; -import type { ExtraCustomType, ProjectJson, ScopedTasksBlock } from "./types-parts/project"; - -/** - * A predicate used by {@link VisConfig.scopedTasks}. - * All listed constraints must match for the block to apply. - */ -export interface ScopedTasksMatch { - /** Match on primary language. */ - language?: string | string[]; - /** Match on project layer. */ - layer?: ProjectJson["layer"] | ProjectJson["layer"][]; - /** Match on project type. */ - projectType?: "application" | "library" | "service" | "tool"; - /** Match on project stack. */ - stack?: ProjectJson["stack"] | ProjectJson["stack"][]; - /** Match projects tagged with any of these tags. */ - tags?: string[]; -} +import type { ExtraCustomType, ScopedTasksBlock } from "./types-parts/project"; export interface VisConfig { /** @@ -2034,4 +2017,4 @@ export type { CodeownersConfig, CodeownersSource, OwnersEntry } from "./types-pa export type { LintFmtAdapterOverride } from "./types-parts/lint"; export type { NativeAuditExclusions, PolicyName } from "./types-parts/policies"; export { POLICY_NAMES } from "./types-parts/policies"; -export type { ExtraCustomType, PackageJson, PackageJsonIndex, ProjectJson, ProjectOptionsIndex, ScopedTasksBlock, VisProjectConfiguration, VisTaskConfig, VisTaskConfigIndex } from "./types-parts/project"; +export type { ExtraCustomType, PackageJson, PackageJsonIndex, ProjectJson, ProjectOptionsIndex, ScopedTasksBlock, ScopedTasksMatch, VisProjectConfiguration, VisTaskConfig, VisTaskConfigIndex } from "./types-parts/project"; diff --git a/packages/tooling/vis/src/deps/workspace-protocol.ts b/packages/tooling/vis/src/deps/workspace-protocol.ts index 5f28e571..3c7b01d3 100644 --- a/packages/tooling/vis/src/deps/workspace-protocol.ts +++ b/packages/tooling/vis/src/deps/workspace-protocol.ts @@ -1,6 +1,7 @@ import { readJsonSync, writeJsonSync } from "@visulima/fs"; import { resolveIndentForExistingFile } from "../util/editorconfig"; +import { setNestedField } from "../util/nested-field"; import type { DepInstance, DepType } from "../util/workspace-deps"; /** @@ -66,32 +67,6 @@ export const lintWorkspaceProtocol = (instances: DepInstance[], options: Workspa return issues; }; -const setNestedField = (object: Record, dotPath: string, key: string, value: string): void => { - const parts = dotPath.split("."); - let current = object; - - for (let index = 0; index < parts.length - 1; index += 1) { - const part = parts[index] as string; - const next = current[part]; - - if (typeof next !== "object" || next === null) { - current[part] = {}; - } - - current = current[part] as Record; - } - - const lastPart = parts.at(-1) as string; - let block = current[lastPart]; - - if (typeof block !== "object" || block === null) { - block = {}; - current[lastPart] = block; - } - - (block as Record)[key] = value; -}; - export interface ApplyWorkspaceProtocolFixesOptions { /** Disable `.editorconfig` indent discovery; falls back to file-content sniffing. */ useEditorconfig?: boolean; diff --git a/packages/tooling/vis/src/release/core/orchestrator/context.ts b/packages/tooling/vis/src/release/core/orchestrator/context.ts index 1c70e7b5..dd42cf4d 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/context.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/context.ts @@ -5,13 +5,27 @@ import { VisReleaseError } from "../../errors"; import type { PackageManifest, PerPackageReleaseConfig, ReleasePlan, VisReleaseConfig, WorkspacePackage } from "../../types"; import { readChangeFiles } from "../change-file-reader"; import { detectCurrentBranch, resolveChannel } from "../channels"; +import type { ComposerProject } from "../composer-projects"; +import type { DartProject } from "../dart-projects"; import { DependencyGraph } from "../dep-graph"; import { ecosystemPerPackage, isDiscoveryEnabled, RELEASE_ECOSYSTEMS } from "../ecosystems"; +import type { GemProject } from "../gem-projects"; +import type { GoProject } from "../go-projects"; +import type { GradleProject } from "../gradle-projects"; +import type { HexProject } from "../hex-projects"; +import type { MavenProject } from "../maven-projects"; +import type { NuGetProject } from "../nuget-projects"; import { createAdapter, detectPackageManager } from "../package-managers/detect"; import type { PackageManagerAdapter } from "../package-managers/interface"; +import type { PreModeFile } from "../pre-mode"; import { assembleReleasePlan } from "../release-plan"; import { createShellRunner } from "../shell-runner"; +import type { SwiftProject } from "../swift-projects"; import { discoverPackages } from "../workspace"; +import type { ZigProject } from "../zig-projects"; + +/** A project any discovered ecosystem can return. The ecosystem table erases this per entry. */ +export type EcosystemProject = ComposerProject | DartProject | GemProject | GoProject | GradleProject | HexProject | MavenProject | NuGetProject | SwiftProject | WorkspacePackage | ZigProject; export interface OrchestratorContext { branch?: string; @@ -43,10 +57,10 @@ export interface OrchestratorContext { * while exit-pending cleanup also fires. Undefined when no pre.json * exists. */ - preMode?: import("../pre-mode").PreModeFile; + preMode?: PreModeFile; /** Projects each discovered ecosystem produced, keyed by its id (`maven`, `gradle`, ...). An id is absent when its discover flag is off. */ - projectsByEcosystem?: ReadonlyMap>; + projectsByEcosystem?: ReadonlyMap>; } export interface BuildContextOptions { @@ -181,7 +195,7 @@ export const buildContext = async (options: BuildContextOptions = {}): Promise>(); + const projectsByEcosystem = new Map>(); if (RELEASE_ECOSYSTEMS.some((eco) => isDiscoveryEnabled(config, eco))) { for (const eco of RELEASE_ECOSYSTEMS) { @@ -206,7 +220,8 @@ export const buildContext = async (options: BuildContextOptions = {}): Promise eco.toPackage(project))]; - projectsByEcosystem.set(eco.id, projects); + // The table erases each ecosystem's project type; `discover` returns that ecosystem's own EcosystemProject members. + projectsByEcosystem.set(eco.id, projects as ReadonlyArray); } // Discovered modules are named by their own coordinates (bare `core`, `group:artifact`, …), so a diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-loop.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-loop.ts new file mode 100644 index 00000000..3007fdaf --- /dev/null +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-loop.ts @@ -0,0 +1,219 @@ +import type { PackageManifest } from "../../types"; +import { parseCatalogs } from "../catalog"; +import { createVersionActions } from "../version-actions/registry"; +import { resolveVersionActionsId } from "../workspace"; +import type { PublishWave } from "./publish-wave"; +import { persistWaveState } from "./publish-wave"; +import { shouldProvenance } from "./remote-releases"; + +/** + * Publish each release in the wave via its resolved versionActions, in + * topological order so dependencies publish before dependents. + */ +export const runPublishLoop = async (wave: PublishWave): Promise => { + const { context, options, releases, result } = wave; + // Build catalog for catalog: rewriting (only relevant for pnpm; harmless otherwise) + const catalogYaml = await context.pm.readCatalogYaml(context.cwd); + const catalogs = parseCatalogs(catalogYaml); + + // Topo-sort the (possibly resume-filtered) release subset + const order = context.depGraph.topologicalSort(releases.map((r) => r.name)); + + // O(1) lookup by name in the publish loop (was O(n²) via Array.find) + const releaseByName = new Map(releases.map((r) => [r.name, r] as const)); + + // Names that must not have dependents published after them: either the + // package's own publish threw, or it was skipped because one of *its* + // runtime dependencies was already in this set. Topo order guarantees a + // dependency is processed before its dependents, so by the time we + // reach a dependent every blocking ancestor is already recorded here. + const failedNames = new Set(); + + // Build name → versioned manifest map for protocol resolution + const versionedManifestByName = new Map(); + + for (const release of context.plan.releases) { + const pkg = context.depGraph.getPackage(release.name); + + if (pkg) { + versionedManifestByName.set(release.name, { ...pkg.manifest, version: release.newVersion }); + } + } + + // Lifecycle plugins (loaded once for the wave). + const pluginBase = { config: context.config, cwd: context.cwd }; + const pluginHooks = context.config.plugins?.length ? await import("../plugins") : undefined; + + for (const name of order) { + const release = releaseByName.get(name); + + if (!release) { + // Should never happen — the topo sort runs over the same names + // we put into releaseByName. If it does, surface it via the + // result.skipped channel so downstream tooling can spot the bug. + result.skipped.push({ name, reason: "topo-sort returned a name not in the release plan (internal bug)" }); + continue; + } + + const pkg = context.depGraph.getPackage(name); + + if (!pkg) { + continue; + } + + // Don't orphan a dependent. If one of this package's internal + // runtime dependencies failed to publish (or was itself skipped + // for the same reason), the `workspace:` range we rewrite at pack + // time would resolve to a version that never reached the registry, + // leaving consumers with an uninstallable tree. Skip the cascade + // and surface why. devDependencies are excluded — they aren't + // installed by downstream consumers and the topo sort already + // ignores them for ordering. + const blockingDeps = [ + ...new Set( + context.depGraph + .getDependencies(name) + .filter((dep) => dep.kind !== "devDependencies" && failedNames.has(dep.name)) + .map((dep) => dep.name), + ), + ]; + + if (blockingDeps.length > 0) { + result.skipped.push({ + name, + reason: `dependency-failed: not published because ${blockingDeps.join(", ")} failed to publish (publishing would orphan this package's dependency range)`, + }); + failedNames.add(name); + continue; + } + + const perPkg = context.perPackageConfig.get(name) ?? {}; + const actionsId = resolveVersionActionsId(pkg, perPkg); + const actions = options.publishActionsOverride ?? createVersionActions(actionsId); + + const tag = options.tag ?? context.channel?.tag; + + // willPublish gate: a plugin can veto this package's publish. + if (pluginHooks) { + const verdict = await pluginHooks.runWillPublishHooks(pluginBase, { + dir: pkg.dir, + name, + oldVersion: release.oldVersion, + version: release.newVersion, + }); + + if (verdict.skip) { + result.skipped.push({ name, reason: `plugin-skipped${verdict.by ? ` by "${verdict.by}"` : ""}` }); + // Treat a veto like a non-publish: mark it so dependents later + // in the topo order are skipped by the `blockingDeps` guard + // rather than published against a `workspace:` range that + // resolves to a version this veto kept off the registry. + failedNames.add(name); + continue; + } + } + + // Resume the existing stage when staged.json already holds an + // entry for this exact (name, version). The action's publish + // method short-circuits pack+publish and resumes the wait — + // re-uploading would either be rejected by npm or create a + // parallel stage. + const existingStage = wave.stagedRegistry.pending.find((entry) => entry.name === name && entry.version === release.newVersion); + + try { + const out = await actions.publish({ + catalogs, + cleanPackageJsonConfig: context.config.publish?.cleanPackageJson, + dryRun: options.dryRun, + otp: options.otp, + perPackageConfig: perPkg, + pkg, + pm: context.pm, + provenance: shouldProvenance(context), + registry: perPkg.registry, + release, + resumeStageId: existingStage?.id, + tag, + versionedManifestByName, + workspaceConfig: context.config, + }); + + // Drain any stale pending-stage entries for this exact + // (package, version) regardless of outcome. Covers two paths: + // - approved in this run (npm.ts clears out.stageId, but the + // entry we wrote in a prior wave is still in staged.json) + // - already-published (operator approved a timed-out stage + // out-of-band via npmjs.com UI; we never see a stageId + // here, just a "version is now live" signal) + if (out.published || out.alreadyPublished) { + const idsToDrop = wave.stagedRegistry.pending + .filter((entry) => entry.name === name && entry.version === release.newVersion) + .map((entry) => entry.id); + + if (idsToDrop.length > 0) { + wave.stagedRegistry = wave.modules.registry.removePendingStages(wave.stagedRegistry, idsToDrop); + } + } + + if (out.published) { + result.published.push({ name, stageId: out.stageId, tarball: out.tarball, version: release.newVersion }); + wave.state.published.push(`${name}@${release.newVersion}`); + + // afterPublish: post-effect hook (errors logged, not fatal). + if (pluginHooks) { + await pluginHooks.runAfterPublishHooks(pluginBase, { dir: pkg.dir, name, oldVersion: release.oldVersion, version: release.newVersion }); + } + } else if (out.alreadyPublished) { + result.skipped.push({ name, reason: "already-published" }); + // Treat as published for resume purposes — re-running won't try again. + wave.state.published.push(`${name}@${release.newVersion}`); + } else { + result.skipped.push({ name, reason: out.output ?? "skipped" }); + + // Rejected / timed-out stages flow through here. Record + // them in the persistent registry so the operator can + // come back later (workflow re-run, `stage approve --all`, + // npmjs.com UI) without losing the id. + if (out.stageId) { + const reason = (out.output ?? "").startsWith("stage-rejected") ? "rejected" : "timeout"; + + wave.stagedRegistry = wave.modules.registry.upsertPendingStages(wave.stagedRegistry, [ + { + id: out.stageId, + name, + reason, + stagedAt: new Date().toISOString(), + tag: tag ?? "latest", + version: release.newVersion, + }, + ]); + } + } + + // Per-package durability: persist + (for lockInGit) commit/push + // after each package so a death anywhere in the loop lets a fresh + // runner resume with the exact already-published set. + await persistWaveState(wave); + } catch (error) { + result.failed.push({ name, reason: (error as Error).message }); + // Mark so dependents downstream in the topo order are skipped + // rather than published against a missing dependency version. + failedNames.add(name); + } + } + + // afterPublishAll: post-effect wave summary hook (errors logged, not fatal). + if (pluginHooks) { + await pluginHooks.runAfterPublishAllHooks(pluginBase, { + failed: result.failed.map((f) => { + return { name: f.name, reason: f.reason }; + }), + published: result.published.map((p) => { + return { name: p.name, version: p.version }; + }), + skipped: result.skipped.map((s) => { + return { name: s.name, reason: s.reason }; + }), + }); + } +}; diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-post.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-post.ts new file mode 100644 index 00000000..15d5e5c3 --- /dev/null +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-post.ts @@ -0,0 +1,394 @@ +import type { resolveSyncTagGroups } from "../group-tags"; +import type { PublishWave } from "./publish-wave"; +import { persistWaveState } from "./publish-wave"; +import { createRemoteReleases } from "./remote-releases"; + +/** + * Persist + commit the pending-stage registry. Runs on every publish + * wave (success, partial, all-skipped) so the tracked file always + * reflects what's live on npm. The `[skip ci]` tag prevents this + * chore commit from triggering another release cycle. + * + * Commit message reflects the delta direction so the git log reads + * sensibly without having to diff the file: "record" for net-new + * pending entries, "drain" for net-removed entries, "update" for + * mixed waves. + */ +export const commitStagedRegistry = async (wave: PublishWave): Promise => { + const { context, options, runner } = wave; + + if (!options.dryRun) { + const previous = wave.priorRegistrySnapshot.pending.length; + const next = wave.stagedRegistry.pending.length; + const write = await wave.modules.registry.writeStagedRegistry(context.cwd, wave.changesDir, wave.stagedRegistry); + + if (write.changed) { + const { stageAndCommitFile } = await import("../git"); + let message: string; + + if (write.removed) { + message = "chore(release): clear pending stage registry [skip ci]"; + } else if (next > previous) { + const added = next - previous; + + message = `chore(release): record ${added} new pending stage${added === 1 ? "" : "s"} [skip ci]`; + } else if (next < previous) { + const removed = previous - next; + + message = `chore(release): drain ${removed} resolved stage${removed === 1 ? "" : "s"} [skip ci]`; + } else { + // Same count but different content (e.g. a stage was + // drained and a new one staged in the same wave). + message = `chore(release): update pending stage registry (${next} pending) [skip ci]`; + } + + try { + await stageAndCommitFile({ cwd: context.cwd, runner }, write.path, message, { + author: context.config.gitUser, + push: !options.noPush, + sign: context.config.gitSignCommits === true, + }); + } catch (error) { + // Don't fail the whole publish for a registry-commit + // hiccup — the file is on disk; the next run will pick it + // up and retry the commit. Log to stderr only; we don't + // pollute `result.skipped[]` with infrastructure noise. + process.stderr.write(`[vis release] Warning: could not commit ${write.path}: ${(error as Error).message}\n`); + } + } + } +}; + +/** + * Workspace-level changelog wave entry (C5 fix). Written from the + * publish phase — AFTER tag-push — using `result.published[]` so a + * stage rejected at the gate never produces an orphan wave entry. + * Skipped on dry-run, when config disables it, or when no package + * actually published. + */ +export const writeChangelogWave = async (wave: PublishWave): Promise => { + const { context, options, result, runner } = wave; + + if (!options.dryRun && result.published.length > 0 && context.config.workspaceChangelog !== false) { + const wsRaw = context.config.workspaceChangelog; + const wsConfig = typeof wsRaw === "object" ? wsRaw : undefined; + // Default: only render workspace-level when explicitly opted-in OR when aggregateRelease is enabled. + const aggregate + = typeof context.config.aggregateRelease === "object" ? context.config.aggregateRelease.enabled : context.config.aggregateRelease === true; + + if (wsConfig !== undefined || aggregate) { + try { + const { writeWorkspaceChangelogWave } = await import("../changelog/workspace"); + + // Project the published set back into PlannedRelease + // shape using the plan as the source of metadata + // (changeFiles, oldVersion, kind, etc.). Only releases + // that actually shipped this wave are included. + const publishedNames = new Set(result.published.map((p) => p.name)); + const publishedReleases = context.plan.releases.filter((r) => publishedNames.has(r.name)); + + const waveEntryPath = await writeWorkspaceChangelogWave( + { + changelogConfig: context.config.changelog, + cwd: context.cwd, + workspaceChangelog: context.config.workspaceChangelog, + }, + publishedReleases, + ); + + if (waveEntryPath) { + try { + const { stageAndCommitFile } = await import("../git"); + + await stageAndCommitFile({ cwd: context.cwd, runner }, waveEntryPath, "chore(release): record wave [skip ci]", { + author: context.config.gitUser, + push: !options.noPush, + sign: context.config.gitSignCommits === true, + }); + } catch (error) { + // Soft-fail: the workspace CHANGELOG.md is on + // disk; the next wave will pick up the staged + // file and commit it together with the next + // entry. Don't fail the whole publish for a + // commit hiccup. + process.stderr.write(`[vis release] Warning: could not commit workspace CHANGELOG.md: ${(error as Error).message}\n`); + } + } + } catch (error) { + // Soft-fail: workspace changelog is documentation, not + // a release blocker. + process.stderr.write(`[vis release] Warning: could not write workspace CHANGELOG.md wave entry: ${(error as Error).message}\n`); + } + } + } +}; + +/** + * Post-publish lifecycle: forge releases + PR walker + chat notifications, + * then the cross-runner registry write. Only runs when the publish actually + * shipped and its tags reached the remote. + */ +export const runPostPublish = async (wave: PublishWave, syncTags: ReturnType): Promise => { + const { context, options, result, runner } = wave; + + // Create GH releases (or aggregated release) for every successful publish. + // Post-publish lifecycle: forge releases + PR walker + chat + // notifications. All three run within the same guard (the publish + // actually shipped + tags reached the remote) but `noRelease` + // only suppresses the forge-release write — successWalk + chat + // notifications still fire even when the operator opts out of + // GH/GitLab release pages (e.g. teams managing release notes in + // a separate doc site still want PR comments + Slack pings). + // + // F3 fix (audit): `noRelease` previously gated this whole block, + // which silently disabled both successWalk and notifications. + // Now the inner block is split so each lifecycle step can be + // configured independently. + if (!options.dryRun && result.published.length > 0 && result.tagsPushed) { + if (context.config.publish?.noRelease !== true) { + await createRemoteReleases(context, result, runner, syncTags); + } + + // Walk every referenced PR / issue and post a "released in X.Y.Z" + // sticky comment + `released` label (semantic-release parity). + // Runs after createRemoteReleases so each entry has its release + // URL populated. Soft-fails everything to plan.warnings — the + // publish already succeeded, so a forge hiccup here shouldn't + // roll it back. + // + // C-4 fix: dedupe against `state.walked` so a `--resume` after a + // partial failure doesn't re-walk PRs whose comments + labels + // already landed on run 1. Sticky-comment markers provide + // forge-side idempotency, but skipping the walk entirely saves + // the rate-limited API calls (relevant for waves touching many + // PRs). + try { + const { walkSuccessfulRelease } = await import("../success-walk"); + const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); + const { resolveFormatter } = await import("../changelog/resolve"); + const provider = await detectRemoteProvider(context.cwd, runner, context.config.provider); + const client = createRemoteClient(provider, { + githubHost: context.config.githubHost, + gitlabHost: context.config.gitlabHost, + httpProxy: context.config.httpProxy, + }); + const repo = await client.detectRepoSlug(context.cwd, runner); + const formatter = await resolveFormatter(context.config.changelog, context.cwd); + + // Dedupe against BOTH `state.walked` (per-run dedupe — kept + // for `--resume` semantics on the originating runner) AND + // `stagedRegistry.recentlyWalked` (cross-runner dedupe — the + // committed `.vis/release/staged.json` survives a fresh CI + // runner checkout, so a re-fired workflow on a different + // machine doesn't re-walk the PRs). + const { recordRecentlyWalked } = await import("../staged-registry"); + const alreadyWalked = new Set([...(wave.state.walked ?? []), ...(wave.stagedRegistry.recentlyWalked ?? []).map((entry) => entry.key)]); + const walkable = { + ...result, + published: result.published.filter((p) => !alreadyWalked.has(`${p.name}@${p.version}`)), + }; + + if (walkable.published.length > 0) { + const walkResult = await walkSuccessfulRelease(context, walkable, runner, { + client, + formatter, + repo, + }); + + // Record successful walks so a later --resume doesn't repeat + // them. We optimistically mark the WHOLE walkable set as + // walked even if individual refs failed — the soft-fail + // logic inside walkSuccessfulRelease already turned those + // into warnings, so re-walking would just hit the same + // forge issues. + const walkedKeys = walkable.published.map((p) => `${p.name}@${p.version}`); + + wave.state.walked = [...(wave.state.walked ?? []), ...walkedKeys]; + await persistWaveState(wave); + + // Cross-runner persistence — append to the tracked + // registry too. The follow-up registry write + commit + // sits after the notifications block so one commit + // captures both walked + notified deltas. + wave.stagedRegistry = recordRecentlyWalked(wave.stagedRegistry, walkedKeys); + + if (walkResult.warnings.length > 0) { + context.plan.warnings.push(...walkResult.warnings); + } + } + } catch (error) { + // Top-level safety net — any orchestration error in the walk + // setup itself (provider detect crash, formatter resolution) + // shouldn't fail the publish. + context.plan.warnings.push(`successWalk: could not run post-release walk: ${(error as Error).message}`); + } + + // Fan out post-release notifications (slack / discord / webhook / + // plugins). Runs LAST in the post-publish sequence because chat + // pings should reflect the FINAL state — tags pushed, GH releases + // created, PRs walked + labelled — not an in-progress snapshot. + // Per-channel failures are isolated; the publish itself is + // already complete and immutable at this point. + if (context.config.notifications) { + try { + const { dispatchNotifications } = await import("../notifications/interface"); + const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); + const provider = await detectRemoteProvider(context.cwd, runner, context.config.provider); + const client = createRemoteClient(provider, { + githubHost: context.config.githubHost, + gitlabHost: context.config.gitlabHost, + httpProxy: context.config.httpProxy, + }); + const repo = await client.detectRepoSlug(context.cwd, runner); + + // Pull root package.json#name when available so chat + // messages carry the workspace identity even when the + // git remote slug isn't enough context. + let monorepoName: string | undefined; + + try { + const rootManifestPath = `${context.cwd}/package.json`; + const fs2 = await import("node:fs/promises"); + const rootManifest = JSON.parse(await fs2.readFile(rootManifestPath, "utf8")) as { name?: string }; + + monorepoName = rootManifest.name; + } catch { + // root manifest missing or unreadable — fine, we just + // omit the field. + } + + // C-4 fix: dedupe notifications against state.notified so + // a `--resume` after a partial failure doesn't re-fire + // Slack/Discord pings for releases that already shipped + // + notified on run 1. Unlike successWalk's sticky + // markers, chat webhooks have NO server-side idempotency + // — a re-send produces a duplicate message. + // + // Cross-runner extension: also dedupe against + // `stagedRegistry.recentlyNotified`, which is committed + // to the worktree and therefore survives a fresh CI + // runner clone. `state.notified` alone is per-run + per- + // machine — a re-fired workflow on a different runner + // would otherwise re-fire the chat pings. + const alreadyNotified = new Set([...(wave.state.notified ?? []), ...(wave.stagedRegistry.recentlyNotified ?? []).map((entry) => entry.key)]); + const notifiable = result.published.filter((p) => !alreadyNotified.has(`${p.name}@${p.version}`)); + + // When everything in this wave was already notified on a + // prior run, skip the dispatcher entirely (chat webhooks + // have no server-side idempotency, so a re-send would + // duplicate). Crucially, do NOT return here — the + // cross-runner persistence + post-publish hook below still + // need to run, and the function must return `result`. + if (notifiable.length > 0) { + const notificationResult = await dispatchNotifications( + context.config.notifications, + { + channel: context.channel?.tag, + completedAt: new Date().toISOString(), + ...(monorepoName === undefined ? {} : { monorepoName }), + published: notifiable.map((p) => { + return { + name: p.name, + tag: context.channel?.tag, + ...(p.url === undefined ? {} : { url: p.url }), + version: p.version, + }; + }), + ...(repo === undefined ? {} : { repo }), + skipped: result.skipped.map((s) => { + return { name: s.name, reason: s.reason }; + }), + }, + { warn: (m) => context.plan.warnings.push(m) }, + ); + + // Mark the dispatched-this-run set as notified ONLY when + // at least one channel succeeded — that way a total + // dispatch failure (all channels down) can retry next + // wave instead of being silently skipped forever. The + // sticky-marker / webhook-rate-limit guards keep the + // retry from being too noisy. + if (notificationResult.succeeded.length > 0) { + const notifiedKeys = notifiable.map((p) => `${p.name}@${p.version}`); + + wave.state.notified = [...(wave.state.notified ?? []), ...notifiedKeys]; + await persistWaveState(wave); + + // Cross-runner persistence — same rationale as the + // walked path above. Mutates the in-memory registry; + // the trailing write + commit below captures it. + const { recordRecentlyNotified } = await import("../staged-registry"); + + wave.stagedRegistry = recordRecentlyNotified(wave.stagedRegistry, notifiedKeys); + } + + // B-2: surface the dispatch outcome explicitly so + // `vis release plan` and CI tails can show "notified + // 2/3 channels" instead of guessing from absent + // failure entries. Skipped when all channels succeed + // AND no failures occurred (the silent success case). + if (notificationResult.failed.length > 0 || notificationResult.succeeded.length > 0) { + const total = notificationResult.succeeded.length + notificationResult.failed.length; + + if (notificationResult.failed.length > 0) { + context.plan.warnings.push( + `[notifications] dispatched ${notificationResult.succeeded.length}/${total} channels${notificationResult.succeeded.length > 0 ? ` (succeeded: ${notificationResult.succeeded.join(", ")})` : ""}; ${notificationResult.failed.length} failed.`, + ); + + for (const failure of notificationResult.failed) { + context.plan.warnings.push(`[notifications:${failure.id}] ${failure.error}`); + } + } + } + } + } catch (error) { + // Top-level safety net for the dispatcher itself + // (config materialisation crash, dynamic-import failure). + context.plan.warnings.push(`notifications: dispatch failed: ${(error as Error).message}`); + } + } + + // Cross-runner persistence — commit staged.json IF either + // recentlyNotified or recentlyWalked grew during this wave. + // The initial write at the end of the publish loop only + // captured the pending-stage delta; the walk + notify steps + // run AFTER that point, so a second write picks up their + // contributions. + // + // Soft-failed for the same reason the first commit is: + // staged.json being on disk is enough to recover next wave, + // even if the commit itself hiccupped. + if (!options.dryRun) { + try { + const followUpWrite = await wave.modules.registry.writeStagedRegistry(context.cwd, wave.changesDir, wave.stagedRegistry); + + if (followUpWrite.changed) { + const { stageAndCommitFile } = await import("../git"); + + try { + await stageAndCommitFile( + { cwd: context.cwd, runner }, + followUpWrite.path, + "chore(release): record cross-runner notify/walk dedupe [skip ci]", + { + author: context.config.gitUser, + push: !options.noPush, + sign: context.config.gitSignCommits === true, + }, + ); + } catch (error) { + process.stderr.write( + `[vis release] Warning: could not commit ${followUpWrite.path} (notify/walk dedupe): ${(error as Error).message}\n`, + ); + } + } + } catch (error) { + // Don't fail the whole publish for a registry-write + // hiccup — the cross-runner dedupe degrades gracefully + // back to `state.notified` / `state.walked` (per-run + // only) on the next wave. + process.stderr.write(`[vis release] Warning: could not update cross-runner dedupe registry: ${(error as Error).message}\n`); + } + } + } +}; diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts new file mode 100644 index 00000000..994caf5c --- /dev/null +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts @@ -0,0 +1,206 @@ +import { VisReleaseError } from "../../errors"; +import type { resolveSyncTagGroups } from "../group-tags"; +import type { OrchestratorContext } from "./context"; +import type { PublishWave } from "./publish-wave"; +import { commitPublishLock, persistWaveState } from "./publish-wave"; + +type SyncTagResolution = ReturnType; + +export interface PublishTagPlan { + /** One shared tag per syncGitTag group, created after the per-package tags. */ + groups: SyncTagResolution["groups"]; + /** One entry per release that is not part of a syncGitTag group, in input order. */ + packages: { floatTag?: string; name: string; tag: string; version: string }[]; +} + +/** + * The tags a publish of `releases` creates: a release tag per package (or one + * shared tag per syncGitTag group), and a floating major tag where the rules + * allow it. Shared by the publish-time tag phase and the push-rule preflight, + * so the preflight checks the tags the run will actually create. + */ +export const planPublishTags = async ( + context: OrchestratorContext, + releases: { name: string; version: string }[], + syncTags: SyncTagResolution, +): Promise => { + const { defaultTagFor, floatingMajorTagFor, renderTagPattern } = await import("../git"); + const rootTagPattern = context.config.releaseTagPattern; + const channelName = context.channel?.tag; + const floatingMajorEnabled = context.config.floatingMajorTag === true; + // Floating major tags must NOT move across prerelease channels — + // an unstable `v1` pointer would yank consumers from a stable + // major into a half-finished release. Skipped when the active + // channel resolved to a prerelease identifier. + const isPrerelease = Boolean(context.channel?.prerelease) || Boolean(context.preMode); + const packages: PublishTagPlan["packages"] = []; + + for (const { name, version } of releases) { + if (syncTags.grouped.has(name)) { + continue; + } + + const perPkg = context.perPackageConfig.get(name); + const pattern = perPkg?.releaseTagPattern ?? rootTagPattern; + const tag = pattern ? renderTagPattern(pattern, { channel: channelName, name, version }) : defaultTagFor(name, version); + + // Floating major tag (`-v`) — + // semantic-release #1515 parity. Force-update so the + // pointer always tracks the latest patch / minor under the + // current major. + // + // Conditions to emit: + // - `release.floatingMajorTag === true` + // - active channel is NOT a prerelease (incl. pre-mode) + // - the resolved tag pattern doesn't already contain + // `{major}` (the pattern is already serving that role) + // - the package is NOT private and does NOT set + // `skipNpmPublish` (no published artifact for a + // consumer to pin against — a floating tag would + // just clutter the tag history) + const isPrivate = context.depGraph.getPackage(name)?.manifest?.private === true; + const skipNpmPublish = perPkg?.skipNpmPublish === true; + let floatTag: string | undefined; + + if (floatingMajorEnabled && !isPrerelease && !pattern?.includes("{major}") && !isPrivate && !skipNpmPublish) { + // Scope-qualified (`@acme/cli` → `acme-cli-v1`) so two packages + // with the same unscoped name never retarget each other's tag + // (audit F2); undefined for an empty name or major. + floatTag = floatingMajorTagFor(name, version); + } + + packages.push({ floatTag, name, tag, version }); + } + + return { groups: syncTags.groups, packages }; +}; + +/** + * Create and push release tags for the successfully published packages. + * Returns the syncGitTag resolution so the post-publish phase reuses it. + */ +export const createPublishTags = async (wave: PublishWave): Promise => { + const { context, options, result, runner } = wave; + // syncGitTag groups: members collapse to one shared tag + + // aggregate release. Resolved once for the wave — both the tag-creation + // phase below and createRemoteReleases consume it (no double resolution). + const { resolveSyncTagGroups } = await import("../group-tags"); + const syncTags = resolveSyncTagGroups(context.config, result.published); + + // Create + push tags for successfully-published releases (RFC §14 / §19.1). + if (!options.dryRun && !options.noTag && result.published.length > 0) { + const { createOrUpdateFloatingTag, createTag, pushTags } = await import("../git"); + const plan = await planPublishTags(context, result.published, syncTags); + + for (const { floatTag, name, tag, version } of plan.packages) { + try { + await createTag( + { cwd: context.cwd, runner }, + tag, + `Release ${name}@${version}`, + // First-release bootstraps a brand-new tag history — + // there's no remote to consult, and a left-over orphan + // would simply be re-published this wave. Skip the + // remote pre-check so the very first run on a fresh + // repo doesn't error. + { signing: context.config.signing, skipRemoteCheck: context.firstRelease }, + ); + result.tags.push(tag); + } catch (error) { + // Under `--first-release` a TAG_COLLISION is the "re-run with + // dangling state" recovery path: a freshly-cloned greenfield repo + // can't legitimately produce colliding tags, so it is skipped. + // Any other tag-creation failure leaves the package published but + // untagged, so it is a failure: the command exits non-zero and the + // state file stays so the tag can be retried. + if (context.firstRelease && error instanceof VisReleaseError && error.code === "TAG_COLLISION") { + result.skipped.push({ name: tag, reason: "tag-creation: tag already exists (first-release — skipped)" }); + } else { + result.failed.push({ name: tag, reason: `tag-creation: ${(error as Error).message}` }); + } + } + + // Errors are soft-fail: a floating-tag hiccup must not + // prevent the rest of the wave from publishing. + if (floatTag !== undefined) { + try { + await createOrUpdateFloatingTag({ cwd: context.cwd, runner }, floatTag, { + push: !options.noPush, + signing: context.config.signing, + }); + // Recorded in result.tags so the operator + // sees it on the summary line. + result.tags.push(floatTag); + } catch (error) { + result.skipped.push({ + name: floatTag, + reason: `floating-major-tag: ${(error as Error).message}`, + }); + } + } + } + + // One shared tag per syncGitTag group (created after per-package tags + // so it's a single annotated tag for the whole group). + for (const group of plan.groups) { + try { + await createTag({ cwd: context.cwd, runner }, group.tag, `Release ${group.name} ${group.version}`, { + signing: context.config.signing, + skipRemoteCheck: context.firstRelease, + }); + result.tags.push(group.tag); + } catch (error) { + if (context.firstRelease && error instanceof VisReleaseError && error.code === "TAG_COLLISION") { + result.skipped.push({ name: group.tag, reason: "tag-creation: tag already exists (first-release — skipped)" }); + } else { + result.failed.push({ name: group.tag, reason: `tag-creation: ${(error as Error).message}` }); + } + } + } + + if (!options.noPush && result.tags.length > 0) { + try { + await pushTags({ cwd: context.cwd, runner }); + result.tagsPushed = true; + } catch (error) { + // Soft-fail on the tag push itself (the publish already + // succeeded), but surface a failed entry so the CLI exits + // non-zero and prints the TAG_PUSH_FAILED hint. The user + // can re-run with --resume to retry the push. + result.tagsPushed = false; + result.failed.push({ + name: "_pushTags", + reason: `git push --tags failed: ${(error as Error).message}. Retry with: git push --tags`, + }); + } + } + + // Persist final tag state. + wave.state.tagged = [...result.tags]; + wave.state.pushed = result.tagsPushed; + + await persistWaveState(wave); + + // Successful end-state: clear the state file. "Done" means no + // failures — which includes a `--resume` that finishes with only + // already-published packages and zero new publishes this run. Gating + // on `published.length > 0` would strand the tracked lock in that + // case, so the next runner keeps auto-resuming a finished wave. (The + // empty-plan case returned early above, so reaching here means the + // wave had work and all of it succeeded.) We treat a successful + // publish as done even if `git push --tags` failed — that can be + // retried independently and shouldn't hold the state file open. + if (result.failed.length === 0 && !options.dryRun) { + await wave.modules.state.clearState(context.cwd, wave.changesDir, wave.lockInGit); + + // Commit + push the lock removal so the next run (anywhere) + // sees a clean slate rather than a stale lock that would make + // it skip the whole already-published plan. + if (wave.lockInGit) { + await commitPublishLock(context, runner, wave.changesDir, "close", { noPush: options.noPush }); + } + } + } + + return syncTags; +}; diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts new file mode 100644 index 00000000..4ea5e722 --- /dev/null +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts @@ -0,0 +1,176 @@ +import { DEFAULT_CHANGES_DIR } from "../../config"; +import type { PlannedRelease, StagedRegistryFile, StateFile } from "../../types"; +import type { createShellRunner } from "../shell-runner"; +import type { readStagedRegistry, removePendingStages, upsertPendingStages, writeStagedRegistry } from "../staged-registry"; +import type { clearState, filterPlanByState, newState, readState, writeState } from "../state"; +import { assertNoConflictingPendingStages } from "./apply"; +import type { OrchestratorContext } from "./context"; +import type { PublishContextOptions, PublishContextResult } from "./publish"; + +/** + * Commit + push the git-tracked publish lock (`release.publish.lockInGit`). + * `open` is committed at the start of a wave (so an ephemeral runner can resume + * if this one dies); `progress` checkpoints in-flight state mid-wave; `close` + * commits the lock removal on full success. Soft-fail — a commit hiccup must not + * fail an otherwise-successful publish. A failed *push* is surfaced as a warning + * because it silently breaks cross-runner resume. + */ +export const commitPublishLock = async ( + context: OrchestratorContext, + runner: ReturnType, + changesDir: string, + phase: "close" | "open" | "progress", + options: { noPush?: boolean }, +): Promise => { + const { stageAndCommitFile } = await import("../git"); + const { stateFilePath } = await import("../state"); + + try { + const result = await stageAndCommitFile( + { cwd: context.cwd, runner }, + stateFilePath(context.cwd, changesDir, true), + `chore(release): ${phase} publish lock [skip ci]`, + { + author: context.config.gitUser, + push: !options.noPush, + sign: context.config.gitSignCommits === true, + }, + ); + + if (!options.noPush && result.committed && !result.pushed) { + process.stderr.write( + `[vis release] Warning: publish lock (${phase}) was committed locally but could not be pushed — cross-runner resume may use a stale lock.\n`, + ); + } + } catch (error) { + process.stderr.write(`[vis release] Warning: could not commit publish lock (${phase}): ${(error as Error).message}\n`); + } +}; + +/** + * State shared by every phase of one publish wave. Fields that a later phase + * reassigns (`state`, `stagedRegistry`) are read from the wave at use time, + * never copied into a phase's locals. + */ +export interface PublishWave { + changesDir: string; + context: OrchestratorContext; + lockInGit: boolean; + /** State and staged-registry functions, loaded once per wave. */ + modules: PublishWaveModules; + options: PublishContextOptions; + /** Registry as it stood before the publish loop; drives the commit message delta. */ + priorRegistrySnapshot: StagedRegistryFile; + releases: PlannedRelease[]; + result: PublishContextResult; + runner: ReturnType; + stagedRegistry: StagedRegistryFile; + state: StateFile; +} + +interface PublishWaveModules { + registry: { + readStagedRegistry: typeof readStagedRegistry; + removePendingStages: typeof removePendingStages; + upsertPendingStages: typeof upsertPendingStages; + writeStagedRegistry: typeof writeStagedRegistry; + }; + state: { + clearState: typeof clearState; + filterPlanByState: typeof filterPlanByState; + newState: typeof newState; + readState: typeof readState; + writeState: typeof writeState; + }; +} + +/** Phase-independent inputs handed to {@link openPublishWave}. */ +interface PublishWaveBase { + context: OrchestratorContext; + options: PublishContextOptions; + result: PublishContextResult; + runner: ReturnType; +} + +/** + * Persist the in-progress state at a resume checkpoint. When the lock is + * git-tracked, also commit + push it so a fresh runner resumes from this + * exact point (published / tagged / walked / notified) instead of + * replaying side effects. The commit is a no-op (no push) when `state` + * is unchanged, so only real progress reaches the remote. + */ +export const persistWaveState = async (wave: PublishWave): Promise => { + if (wave.options.dryRun) { + return; + } + + await wave.modules.state.writeState(wave.context.cwd, wave.changesDir, wave.state, wave.lockInGit); + + if (wave.lockInGit) { + await commitPublishLock(wave.context, wave.runner, wave.changesDir, "progress", { noPush: wave.options.noPush }); + } +}; + +/** + * Read the resume state, guard against conflicting pending stages, and start + * the wave (state file + `open` lock commit) before any package is published. + */ +export const openPublishWave = async (base: PublishWaveBase, modules: PublishWaveModules): Promise => { + const { context, options, runner } = base; + // Resume support: read state file, skip already-published packages. + const changesDir = context.config.changesDir ?? DEFAULT_CHANGES_DIR; + // Git-tracked publish lock (RFC §19.1): persists state to + // a committed `publish-lock.json` so an ephemeral runner can resume. + // When enabled, a committed lock means "resume" even without --resume — + // its mere presence signals an in-progress publish from another runner. + const lockInGit = context.config.publish?.lockInGit === true; + let state = options.resume || lockInGit ? await modules.state.readState(context.cwd, changesDir, lockInGit) : undefined; + + // A git-tracked lock is auto-resumed by presence, so a lock left behind + // by a prior wave (e.g. one whose "close" commit/push failed) could pin + // its stale channel tag onto this run. The current branch is the + // authoritative channel — refresh it. (Per-package `name@version` resume + // filtering still self-heals which packages publish.) + if (state && lockInGit) { + state.channel = context.channel?.tag; + } + + let stagedRegistry = await modules.registry.readStagedRegistry(context.cwd, changesDir); + + // Guard the publish phase too — `vis release publish` can run on its + // own (no preceding `version` step in CI), so the version-phase guard + // isn't enough. Pending stages on the same package mean the operator + // must resolve them first or the new publish will create a parallel + // tarball. Throws `STAGE_PENDING`; the CLI handler catches it and + // prints the hint. + await assertNoConflictingPendingStages(context, "publish", stagedRegistry, { runner }); + + // Re-read in case the guard's self-heal mutated the registry — the + // mutated copy is what we want for downstream upsert/remove work. + stagedRegistry = await modules.registry.readStagedRegistry(context.cwd, changesDir); + + // Snapshot the registry as it stands BEFORE the publish loop. Used + // at the end of the wave to compute the delta for the commit + // message ("record N" vs "drain N" vs "update"). + const priorRegistrySnapshot = { ...stagedRegistry, pending: [...stagedRegistry.pending] }; + + let { releases } = context.plan; + + if (state) { + releases = modules.state.filterPlanByState(releases, state); + } else { + state = modules.state.newState(context.channel?.tag, context.plan.releases); + + if (!options.dryRun) { + await modules.state.writeState(context.cwd, changesDir, state, lockInGit); + + // Commit + push the tracked lock at the start of the wave so a + // different (ephemeral) runner can resume if this one dies. + if (lockInGit) { + await commitPublishLock(context, runner, changesDir, "open", { noPush: options.noPush }); + } + } + } + + return { ...base, changesDir, lockInGit, modules, priorRegistrySnapshot, releases, stagedRegistry, state }; +}; diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish.ts b/packages/tooling/vis/src/release/core/orchestrator/publish.ts index 0fef2c32..a8d3f690 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/publish.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/publish.ts @@ -1,14 +1,12 @@ import { DEFAULT_CHANGES_DIR } from "../../config"; -import { VisReleaseError } from "../../errors"; -import type { PackageManifest } from "../../types"; -import { parseCatalogs } from "../catalog"; import { createShellRunner } from "../shell-runner"; import type { VersionActions } from "../version-actions/interface"; -import { createVersionActions } from "../version-actions/registry"; -import { resolveVersionActionsId } from "../workspace"; -import { assertNoConflictingPendingStages, runHook } from "./apply"; +import { runHook } from "./apply"; import type { OrchestratorContext } from "./context"; -import { createRemoteReleases, shouldProvenance } from "./remote-releases"; +import { runPublishLoop } from "./publish-loop"; +import { commitStagedRegistry, runPostPublish, writeChangelogWave } from "./publish-post"; +import { createPublishTags, planPublishTags } from "./publish-tags"; +import { openPublishWave } from "./publish-wave"; // ── Publish phase ────────────────────────────────────────────────── export interface PublishContextOptions { @@ -49,46 +47,6 @@ export interface PublishContextResult { tagsPushed: boolean; } -/** - * Commit + push the git-tracked publish lock (`release.publish.lockInGit`). - * `open` is committed at the start of a wave (so an ephemeral runner can resume - * if this one dies); `progress` checkpoints in-flight state mid-wave; `close` - * commits the lock removal on full success. Soft-fail — a commit hiccup must not - * fail an otherwise-successful publish. A failed *push* is surfaced as a warning - * because it silently breaks cross-runner resume. - */ -const commitPublishLock = async ( - context: OrchestratorContext, - runner: ReturnType, - changesDir: string, - phase: "close" | "open" | "progress", - options: { noPush?: boolean }, -): Promise => { - const { stageAndCommitFile } = await import("../git"); - const { stateFilePath } = await import("../state"); - - try { - const result = await stageAndCommitFile( - { cwd: context.cwd, runner }, - stateFilePath(context.cwd, changesDir, true), - `chore(release): ${phase} publish lock [skip ci]`, - { - author: context.config.gitUser, - push: !options.noPush, - sign: context.config.gitSignCommits === true, - }, - ); - - if (!options.noPush && result.committed && !result.pushed) { - process.stderr.write( - `[vis release] Warning: publish lock (${phase}) was committed locally but could not be pushed — cross-runner resume may use a stale lock.\n`, - ); - } - } catch (error) { - process.stderr.write(`[vis release] Warning: could not commit publish lock (${phase}): ${(error as Error).message}\n`); - } -}; - /** * Tags a publish of the current plan pushes: release tags it creates (per * package, or one per `syncGitTag` group) and floating major tags it moves. @@ -96,7 +54,6 @@ const commitPublishLock = async ( * because they are never published, so never tagged. */ const plannedPushTags = async (context: OrchestratorContext): Promise<{ created: string[]; updated: string[] }> => { - const { defaultTagFor, floatingMajorTagFor, renderTagPattern } = await import("../git"); const { resolveSyncTagGroups } = await import("../group-tags"); const planned = context.plan.releases .filter((release) => context.depGraph.getPackage(release.name)?.manifest?.private !== true) @@ -104,30 +61,12 @@ const plannedPushTags = async (context: OrchestratorContext): Promise<{ created: return { name: release.name, version: release.newVersion }; }); const syncTags = resolveSyncTagGroups(context.config, planned); - const created = syncTags.groups.map((group) => group.tag); - const updated: string[] = []; - const isPrerelease = Boolean(context.channel?.prerelease) || Boolean(context.preMode); - - for (const { name, version } of planned) { - if (syncTags.grouped.has(name)) { - continue; - } + const plan = await planPublishTags(context, planned, syncTags); - const perPkg = context.perPackageConfig.get(name); - const pattern = perPkg?.releaseTagPattern ?? context.config.releaseTagPattern; - - created.push(pattern ? renderTagPattern(pattern, { channel: context.channel?.tag, name, version }) : defaultTagFor(name, version)); - - if (context.config.floatingMajorTag === true && !isPrerelease && !pattern?.includes("{major}") && perPkg?.skipNpmPublish !== true) { - const floatTag = floatingMajorTagFor(name, version); - - if (floatTag !== undefined) { - updated.push(floatTag); - } - } - } - - return { created, updated }; + return { + created: [...plan.groups.map((group) => group.tag), ...plan.packages.map((pkg) => pkg.tag)], + updated: plan.packages.flatMap((pkg) => (pkg.floatTag === undefined ? [] : [pkg.floatTag])), + }; }; /** @@ -218,57 +157,87 @@ const verifyReleasePushes = async ( }; /** - * Publish each release in the plan via its resolved versionActions. - * Topological order is honored so dependencies publish before dependents. + * In-flight version-PR check (RFC §19.1) — refuse to publish locally if an + * open release PR exists, to catch the cross-machine race where a developer + * runs `vis release publish` while CI is mid-release-PR cycle. Records the + * refusal on `result` and returns `true` when the publish must stop. */ -export const publishContext = async (context: OrchestratorContext, options: PublishContextOptions = {}): Promise => { - const result: PublishContextResult = { failed: [], published: [], skipped: [], tags: [], tagsPushed: false }; - const runner = createShellRunner(); - - if (context.plan.releases.length === 0) { - return result; - } +const refuseWhileReleasePrOpen = async ( + context: OrchestratorContext, + runner: ReturnType, + result: PublishContextResult, +): Promise => { + const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); + let repo: string | undefined; - // In-flight version-PR check (RFC §19.1) — refuse to publish locally - // if an open release PR exists, to catch the cross-machine race where - // a developer runs `vis release publish` while CI is mid-release-PR cycle. - if (!options.dryRun && context.channel?.mode === "version-pr") { - const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); + try { const provider = await detectRemoteProvider(context.cwd, runner, context.config.provider); const client = createRemoteClient(provider, { githubHost: context.config.githubHost, gitlabHost: context.config.gitlabHost, httpProxy: context.config.httpProxy, }); - const repo = await client.detectRepoSlug(context.cwd, runner); - const branch = context.config.versionPr?.branch ?? "vis-release/version-packages"; - if (repo) { - try { - // gh CLI: list open PRs from the version-PR branch. - const list = await runner.run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number"], { - cwd: context.cwd, - silent: true, - }); + repo = await client.detectRepoSlug(context.cwd, runner); + } catch (error) { + result.failed.push({ + name: "_preflight", + reason: `Could not read the remote to check for an open release PR: ${(error as Error).message}`, + }); + + return true; + } + + const branch = context.config.versionPr?.branch ?? "vis-release/version-packages"; - if (list.exitCode === 0 && list.stdout.trim() && list.stdout.trim() !== "[]") { - const parsed = JSON.parse(list.stdout) as { number: number }[]; + if (repo) { + try { + // gh CLI: list open PRs from the version-PR branch. + const list = await runner.run("gh", ["pr", "list", "--head", branch, "--state", "open", "--json", "number"], { + cwd: context.cwd, + silent: true, + }); - if (parsed[0]) { - result.failed.push({ - name: "_preflight", - reason: `Open release PR #${parsed[0].number} exists. Merge or close it before publishing locally. (Override: --no-push then \`git push --tags\` manually.)`, - }); + if (list.exitCode === 0 && list.stdout.trim() && list.stdout.trim() !== "[]") { + const parsed = JSON.parse(list.stdout) as { number: number }[]; - return result; - } + if (parsed[0]) { + result.failed.push({ + name: "_preflight", + reason: `Open release PR #${parsed[0].number} exists. Merge or close it before publishing locally. (Override: --no-push then \`git push --tags\` manually.)`, + }); + + return true; } - } catch { - // gh not available — skip the check (fail-open). } + } catch { + // gh not available — skip the check (fail-open). } } + return false; +}; + +/** + * Publish each release in the plan via its resolved versionActions. + * Topological order is honored so dependencies publish before dependents. + * + * Orchestrates one publish wave: pre-flight guards, the process lock, then + * the wave phases (open, per-package loop, tags, registry commit, changelog, + * post-publish). Phase bodies live in `./publish-*.ts`. + */ +export const publishContext = async (context: OrchestratorContext, options: PublishContextOptions = {}): Promise => { + const result: PublishContextResult = { failed: [], published: [], skipped: [], tags: [], tagsPushed: false }; + const runner = createShellRunner(); + + if (context.plan.releases.length === 0) { + return result; + } + + if (!options.dryRun && context.channel?.mode === "version-pr" && (await refuseWhileReleasePrOpen(context, runner, result))) { + return result; + } + if (!options.dryRun && context.config.prePublishCommand) { try { await runHook(context.cwd, context.config.prePublishCommand, "prePublishCommand"); @@ -282,7 +251,15 @@ export const publishContext = async (context: OrchestratorContext, options: Publ // Fail before publishing when the pushes later in this run would be // rejected, so packages don't land on the registry without their tags. if (!options.dryRun && !options.noPush) { - const pushError = await verifyReleasePushes(context, runner, { noTag: options.noTag === true }); + let pushError: string | undefined; + + try { + pushError = await verifyReleasePushes(context, runner, { noTag: options.noTag === true }); + } catch (error) { + result.failed.push({ name: "_preflight", reason: `Could not verify the release pushes: ${(error as Error).message}` }); + + return result; + } if (pushError !== undefined) { result.failed.push({ name: "_pushAccess", reason: pushError }); @@ -310,817 +287,18 @@ export const publishContext = async (context: OrchestratorContext, options: Publ } try { - // Resume support: read state file, skip already-published packages. - const { clearState, filterPlanByState, newState, readState, writeState } = await import("../state"); - const { readStagedRegistry, removePendingStages, upsertPendingStages, writeStagedRegistry } = await import("../staged-registry"); - const changesDir = context.config.changesDir ?? DEFAULT_CHANGES_DIR; - // Git-tracked publish lock (RFC §19.1): persists state to - // a committed `publish-lock.json` so an ephemeral runner can resume. - // When enabled, a committed lock means "resume" even without --resume — - // its mere presence signals an in-progress publish from another runner. - const lockInGit = context.config.publish?.lockInGit === true; - let state = options.resume || lockInGit ? await readState(context.cwd, changesDir, lockInGit) : undefined; - - // A git-tracked lock is auto-resumed by presence, so a lock left behind - // by a prior wave (e.g. one whose "close" commit/push failed) could pin - // its stale channel tag onto this run. The current branch is the - // authoritative channel — refresh it. (Per-package `name@version` resume - // filtering still self-heals which packages publish.) - if (state && lockInGit) { - state.channel = context.channel?.tag; - } - - // Persist the in-progress state at a resume checkpoint. When the lock is - // git-tracked, also commit + push it so a fresh runner resumes from this - // exact point (published / tagged / walked / notified) instead of - // replaying side effects. The commit is a no-op (no push) when `state` - // is unchanged, so only real progress reaches the remote. - const persistState = async (): Promise => { - if (options.dryRun || !state) { - return; - } - - await writeState(context.cwd, changesDir, state, lockInGit); - - if (lockInGit) { - await commitPublishLock(context, runner, changesDir, "progress", { noPush: options.noPush }); - } - }; - - let stagedRegistry = await readStagedRegistry(context.cwd, changesDir); - - // Guard the publish phase too — `vis release publish` can run on its - // own (no preceding `version` step in CI), so the version-phase guard - // isn't enough. Pending stages on the same package mean the operator - // must resolve them first or the new publish will create a parallel - // tarball. Throws `STAGE_PENDING`; the CLI handler catches it and - // prints the hint. - await assertNoConflictingPendingStages(context, "publish", stagedRegistry, { runner }); - - // Re-read in case the guard's self-heal mutated the registry — the - // mutated copy is what we want for downstream upsert/remove work. - stagedRegistry = await readStagedRegistry(context.cwd, changesDir); - - // Snapshot the registry as it stands BEFORE the publish loop. Used - // at the end of the wave to compute the delta for the commit - // message ("record N" vs "drain N" vs "update"). - const priorRegistrySnapshot = { ...stagedRegistry, pending: [...stagedRegistry.pending] }; - - let { releases } = context.plan; - - if (state) { - releases = filterPlanByState(releases, state); - } else { - state = newState(context.channel?.tag, context.plan.releases); - - if (!options.dryRun) { - await writeState(context.cwd, changesDir, state, lockInGit); - - // Commit + push the tracked lock at the start of the wave so a - // different (ephemeral) runner can resume if this one dies. - if (lockInGit) { - await commitPublishLock(context, runner, changesDir, "open", { noPush: options.noPush }); - } - } - } - - // Build catalog for catalog: rewriting (only relevant for pnpm; harmless otherwise) - const catalogYaml = await context.pm.readCatalogYaml(context.cwd); - const catalogs = parseCatalogs(catalogYaml); - - // Topo-sort the (possibly resume-filtered) release subset - const order = context.depGraph.topologicalSort(releases.map((r) => r.name)); - - // O(1) lookup by name in the publish loop (was O(n²) via Array.find) - const releaseByName = new Map(releases.map((r) => [r.name, r] as const)); - - // Names that must not have dependents published after them: either the - // package's own publish threw, or it was skipped because one of *its* - // runtime dependencies was already in this set. Topo order guarantees a - // dependency is processed before its dependents, so by the time we - // reach a dependent every blocking ancestor is already recorded here. - const failedNames = new Set(); - - // Build name → versioned manifest map for protocol resolution - const versionedManifestByName = new Map(); - - for (const release of context.plan.releases) { - const pkg = context.depGraph.getPackage(release.name); - - if (pkg) { - versionedManifestByName.set(release.name, { ...pkg.manifest, version: release.newVersion }); - } - } - - // Lifecycle plugins (loaded once for the wave). - const pluginBase = { config: context.config, cwd: context.cwd }; - const pluginHooks = context.config.plugins?.length ? await import("../plugins") : undefined; - - for (const name of order) { - const release = releaseByName.get(name); - - if (!release) { - // Should never happen — the topo sort runs over the same names - // we put into releaseByName. If it does, surface it via the - // result.skipped channel so downstream tooling can spot the bug. - result.skipped.push({ name, reason: "topo-sort returned a name not in the release plan (internal bug)" }); - continue; - } - - const pkg = context.depGraph.getPackage(name); - - if (!pkg) { - continue; - } - - // Don't orphan a dependent. If one of this package's internal - // runtime dependencies failed to publish (or was itself skipped - // for the same reason), the `workspace:` range we rewrite at pack - // time would resolve to a version that never reached the registry, - // leaving consumers with an uninstallable tree. Skip the cascade - // and surface why. devDependencies are excluded — they aren't - // installed by downstream consumers and the topo sort already - // ignores them for ordering. - const blockingDeps = [ - ...new Set( - context.depGraph - .getDependencies(name) - .filter((dep) => dep.kind !== "devDependencies" && failedNames.has(dep.name)) - .map((dep) => dep.name), - ), - ]; - - if (blockingDeps.length > 0) { - result.skipped.push({ - name, - reason: `dependency-failed: not published because ${blockingDeps.join(", ")} failed to publish (publishing would orphan this package's dependency range)`, - }); - failedNames.add(name); - continue; - } - - const perPkg = context.perPackageConfig.get(name) ?? {}; - const actionsId = resolveVersionActionsId(pkg, perPkg); - const actions = options.publishActionsOverride ?? createVersionActions(actionsId); + // Lazily-loaded state modules, loaded in the order the wave has always used. + const stateModule = await import("../state"); + const registryModule = await import("../staged-registry"); + const wave = await openPublishWave({ context, options, result, runner }, { registry: registryModule, state: stateModule }); - const tag = options.tag ?? context.channel?.tag; + await runPublishLoop(wave); - // willPublish gate: a plugin can veto this package's publish. - if (pluginHooks) { - const verdict = await pluginHooks.runWillPublishHooks(pluginBase, { - dir: pkg.dir, - name, - oldVersion: release.oldVersion, - version: release.newVersion, - }); + const syncTags = await createPublishTags(wave); - if (verdict.skip) { - result.skipped.push({ name, reason: `plugin-skipped${verdict.by ? ` by "${verdict.by}"` : ""}` }); - // Treat a veto like a non-publish: mark it so dependents later - // in the topo order are skipped by the `blockingDeps` guard - // rather than published against a `workspace:` range that - // resolves to a version this veto kept off the registry. - failedNames.add(name); - continue; - } - } - - // Resume the existing stage when staged.json already holds an - // entry for this exact (name, version). The action's publish - // method short-circuits pack+publish and resumes the wait — - // re-uploading would either be rejected by npm or create a - // parallel stage. - const existingStage = stagedRegistry.pending.find((entry) => entry.name === name && entry.version === release.newVersion); - - try { - const out = await actions.publish({ - catalogs, - cleanPackageJsonConfig: context.config.publish?.cleanPackageJson, - dryRun: options.dryRun, - otp: options.otp, - perPackageConfig: perPkg, - pkg, - pm: context.pm, - provenance: shouldProvenance(context), - registry: perPkg.registry, - release, - resumeStageId: existingStage?.id, - tag, - versionedManifestByName, - workspaceConfig: context.config, - }); - - // Drain any stale pending-stage entries for this exact - // (package, version) regardless of outcome. Covers two paths: - // - approved in this run (npm.ts clears out.stageId, but the - // entry we wrote in a prior wave is still in staged.json) - // - already-published (operator approved a timed-out stage - // out-of-band via npmjs.com UI; we never see a stageId - // here, just a "version is now live" signal) - if (out.published || out.alreadyPublished) { - const idsToDrop = stagedRegistry.pending - .filter((entry) => entry.name === name && entry.version === release.newVersion) - .map((entry) => entry.id); - - if (idsToDrop.length > 0) { - stagedRegistry = removePendingStages(stagedRegistry, idsToDrop); - } - } - - if (out.published) { - result.published.push({ name, stageId: out.stageId, tarball: out.tarball, version: release.newVersion }); - state.published.push(`${name}@${release.newVersion}`); - - // afterPublish: post-effect hook (errors logged, not fatal). - if (pluginHooks) { - await pluginHooks.runAfterPublishHooks(pluginBase, { dir: pkg.dir, name, oldVersion: release.oldVersion, version: release.newVersion }); - } - } else if (out.alreadyPublished) { - result.skipped.push({ name, reason: "already-published" }); - // Treat as published for resume purposes — re-running won't try again. - state.published.push(`${name}@${release.newVersion}`); - } else { - result.skipped.push({ name, reason: out.output ?? "skipped" }); - - // Rejected / timed-out stages flow through here. Record - // them in the persistent registry so the operator can - // come back later (workflow re-run, `stage approve --all`, - // npmjs.com UI) without losing the id. - if (out.stageId) { - const reason = (out.output ?? "").startsWith("stage-rejected") ? "rejected" : "timeout"; - - stagedRegistry = upsertPendingStages(stagedRegistry, [ - { - id: out.stageId, - name, - reason, - stagedAt: new Date().toISOString(), - tag: tag ?? "latest", - version: release.newVersion, - }, - ]); - } - } - - // Per-package durability: persist + (for lockInGit) commit/push - // after each package so a death anywhere in the loop lets a fresh - // runner resume with the exact already-published set. - await persistState(); - } catch (error) { - result.failed.push({ name, reason: (error as Error).message }); - // Mark so dependents downstream in the topo order are skipped - // rather than published against a missing dependency version. - failedNames.add(name); - } - } - - // afterPublishAll: post-effect wave summary hook (errors logged, not fatal). - if (pluginHooks) { - await pluginHooks.runAfterPublishAllHooks(pluginBase, { - failed: result.failed.map((f) => { - return { name: f.name, reason: f.reason }; - }), - published: result.published.map((p) => { - return { name: p.name, version: p.version }; - }), - skipped: result.skipped.map((s) => { - return { name: s.name, reason: s.reason }; - }), - }); - } - - // syncGitTag groups: members collapse to one shared tag + - // aggregate release. Resolved once for the wave — both the tag-creation - // phase below and createRemoteReleases consume it (no double resolution). - const { resolveSyncTagGroups } = await import("../group-tags"); - const syncTags = resolveSyncTagGroups(context.config, result.published); - - // Create + push tags for successfully-published releases (RFC §14 / §19.1). - if (!options.dryRun && !options.noTag && result.published.length > 0) { - const { createOrUpdateFloatingTag, createTag, defaultTagFor, floatingMajorTagFor, pushTags, renderTagPattern } = await import("../git"); - const rootTagPattern = context.config.releaseTagPattern; - - const channelName = context.channel?.tag; - const floatingMajorEnabled = context.config.floatingMajorTag === true; - // Floating major tags must NOT move across prerelease channels — - // an unstable `v1` pointer would yank consumers from a stable - // major into a half-finished release. Skipped when the active - // channel resolved to a prerelease identifier. - const isPrerelease = Boolean(context.channel?.prerelease) || Boolean(context.preMode); - - for (const { name, version } of result.published) { - if (syncTags.grouped.has(name)) { - continue; - } - - const perPkgPattern = context.perPackageConfig.get(name)?.releaseTagPattern; - const pattern = perPkgPattern ?? rootTagPattern; - const tag = pattern ? renderTagPattern(pattern, { channel: channelName, name, version }) : defaultTagFor(name, version); - - try { - await createTag( - { cwd: context.cwd, runner }, - tag, - `Release ${name}@${version}`, - // First-release bootstraps a brand-new tag history — - // there's no remote to consult, and a left-over orphan - // would simply be re-published this wave. Skip the - // remote pre-check so the very first run on a fresh - // repo doesn't error. - { signing: context.config.signing, skipRemoteCheck: context.firstRelease }, - ); - result.tags.push(tag); - } catch (error) { - // Under `--first-release` a TAG_COLLISION is the "re-run with - // dangling state" recovery path: a freshly-cloned greenfield repo - // can't legitimately produce colliding tags, so it is skipped. - // Any other tag-creation failure leaves the package published but - // untagged, so it is a failure: the command exits non-zero and the - // state file stays so the tag can be retried. - if (context.firstRelease && error instanceof VisReleaseError && error.code === "TAG_COLLISION") { - result.skipped.push({ name: tag, reason: "tag-creation: tag already exists (first-release — skipped)" }); - } else { - result.failed.push({ name: tag, reason: `tag-creation: ${(error as Error).message}` }); - } - } - - // Floating major tag (`-v`) — - // semantic-release #1515 parity. Force-update so the - // pointer always tracks the latest patch / minor under the - // current major. - // - // Conditions to emit: - // - `release.floatingMajorTag === true` - // - active channel is NOT a prerelease (incl. pre-mode) - // - the resolved tag pattern doesn't already contain - // `{major}` (the pattern is already serving that role) - // - the package is NOT private and does NOT set - // `skipNpmPublish` (no published artifact for a - // consumer to pin against — a floating tag would - // just clutter the tag history) - // - // Errors are soft-fail: a floating-tag hiccup must not - // prevent the rest of the wave from publishing. - const pkgForFloat = context.depGraph.getPackage(name); - const perPkgForFloat = context.perPackageConfig.get(name); - const isPrivate = pkgForFloat?.manifest?.private === true; - const skipNpmPublish = perPkgForFloat?.skipNpmPublish === true; - - if (floatingMajorEnabled && !isPrerelease && !pattern?.includes("{major}") && !isPrivate && !skipNpmPublish) { - // Scope-qualified (`@acme/cli` → `acme-cli-v1`) so two packages - // with the same unscoped name never retarget each other's tag - // (audit F2); undefined for an empty name or major. - const floatTag = floatingMajorTagFor(name, version); - - if (floatTag !== undefined) { - try { - await createOrUpdateFloatingTag({ cwd: context.cwd, runner }, floatTag, { - push: !options.noPush, - signing: context.config.signing, - }); - // Recorded in result.tags so the operator - // sees it on the summary line. - result.tags.push(floatTag); - } catch (error) { - result.skipped.push({ - name: floatTag, - reason: `floating-major-tag: ${(error as Error).message}`, - }); - } - } - } - } - - // One shared tag per syncGitTag group (created after per-package tags - // so it's a single annotated tag for the whole group). - for (const group of syncTags.groups) { - try { - await createTag({ cwd: context.cwd, runner }, group.tag, `Release ${group.name} ${group.version}`, { - signing: context.config.signing, - skipRemoteCheck: context.firstRelease, - }); - result.tags.push(group.tag); - } catch (error) { - if (context.firstRelease && error instanceof VisReleaseError && error.code === "TAG_COLLISION") { - result.skipped.push({ name: group.tag, reason: "tag-creation: tag already exists (first-release — skipped)" }); - } else { - result.failed.push({ name: group.tag, reason: `tag-creation: ${(error as Error).message}` }); - } - } - } - - if (!options.noPush && result.tags.length > 0) { - try { - await pushTags({ cwd: context.cwd, runner }); - result.tagsPushed = true; - } catch (error) { - // Soft-fail on the tag push itself (the publish already - // succeeded), but surface a failed entry so the CLI exits - // non-zero and prints the TAG_PUSH_FAILED hint. The user - // can re-run with --resume to retry the push. - result.tagsPushed = false; - result.failed.push({ - name: "_pushTags", - reason: `git push --tags failed: ${(error as Error).message}. Retry with: git push --tags`, - }); - } - } - - // Persist final tag state. - state.tagged = [...result.tags]; - state.pushed = result.tagsPushed; - - await persistState(); - - // Successful end-state: clear the state file. "Done" means no - // failures — which includes a `--resume` that finishes with only - // already-published packages and zero new publishes this run. Gating - // on `published.length > 0` would strand the tracked lock in that - // case, so the next runner keeps auto-resuming a finished wave. (The - // empty-plan case returned early above, so reaching here means the - // wave had work and all of it succeeded.) We treat a successful - // publish as done even if `git push --tags` failed — that can be - // retried independently and shouldn't hold the state file open. - if (result.failed.length === 0 && !options.dryRun) { - await clearState(context.cwd, changesDir, lockInGit); - - // Commit + push the lock removal so the next run (anywhere) - // sees a clean slate rather than a stale lock that would make - // it skip the whole already-published plan. - if (lockInGit) { - await commitPublishLock(context, runner, changesDir, "close", { noPush: options.noPush }); - } - } - } - - // Persist + commit the pending-stage registry. Runs on every publish - // wave (success, partial, all-skipped) so the tracked file always - // reflects what's live on npm. The `[skip ci]` tag prevents this - // chore commit from triggering another release cycle. - // - // Commit message reflects the delta direction so the git log reads - // sensibly without having to diff the file: "record" for net-new - // pending entries, "drain" for net-removed entries, "update" for - // mixed waves. - if (!options.dryRun) { - const previous = priorRegistrySnapshot.pending.length; - const next = stagedRegistry.pending.length; - const write = await writeStagedRegistry(context.cwd, changesDir, stagedRegistry); - - if (write.changed) { - const { stageAndCommitFile } = await import("../git"); - let message: string; - - if (write.removed) { - message = "chore(release): clear pending stage registry [skip ci]"; - } else if (next > previous) { - const added = next - previous; - - message = `chore(release): record ${added} new pending stage${added === 1 ? "" : "s"} [skip ci]`; - } else if (next < previous) { - const removed = previous - next; - - message = `chore(release): drain ${removed} resolved stage${removed === 1 ? "" : "s"} [skip ci]`; - } else { - // Same count but different content (e.g. a stage was - // drained and a new one staged in the same wave). - message = `chore(release): update pending stage registry (${next} pending) [skip ci]`; - } - - try { - await stageAndCommitFile({ cwd: context.cwd, runner }, write.path, message, { - author: context.config.gitUser, - push: !options.noPush, - sign: context.config.gitSignCommits === true, - }); - } catch (error) { - // Don't fail the whole publish for a registry-commit - // hiccup — the file is on disk; the next run will pick it - // up and retry the commit. Log to stderr only; we don't - // pollute `result.skipped[]` with infrastructure noise. - process.stderr.write(`[vis release] Warning: could not commit ${write.path}: ${(error as Error).message}\n`); - } - } - } - - // Workspace-level changelog wave entry (C5 fix). Written from the - // publish phase — AFTER tag-push — using `result.published[]` so a - // stage rejected at the gate never produces an orphan wave entry. - // Skipped on dry-run, when config disables it, or when no package - // actually published. - if (!options.dryRun && result.published.length > 0 && context.config.workspaceChangelog !== false) { - const wsRaw = context.config.workspaceChangelog; - const wsConfig = typeof wsRaw === "object" ? wsRaw : undefined; - // Default: only render workspace-level when explicitly opted-in OR when aggregateRelease is enabled. - const aggregate - = typeof context.config.aggregateRelease === "object" ? context.config.aggregateRelease.enabled : context.config.aggregateRelease === true; - - if (wsConfig !== undefined || aggregate) { - try { - const { writeWorkspaceChangelogWave } = await import("../changelog/workspace"); - - // Project the published set back into PlannedRelease - // shape using the plan as the source of metadata - // (changeFiles, oldVersion, kind, etc.). Only releases - // that actually shipped this wave are included. - const publishedNames = new Set(result.published.map((p) => p.name)); - const publishedReleases = context.plan.releases.filter((r) => publishedNames.has(r.name)); - - const waveEntryPath = await writeWorkspaceChangelogWave( - { - changelogConfig: context.config.changelog, - cwd: context.cwd, - workspaceChangelog: context.config.workspaceChangelog, - }, - publishedReleases, - ); - - if (waveEntryPath) { - try { - const { stageAndCommitFile } = await import("../git"); - - await stageAndCommitFile({ cwd: context.cwd, runner }, waveEntryPath, "chore(release): record wave [skip ci]", { - author: context.config.gitUser, - push: !options.noPush, - sign: context.config.gitSignCommits === true, - }); - } catch (error) { - // Soft-fail: the workspace CHANGELOG.md is on - // disk; the next wave will pick up the staged - // file and commit it together with the next - // entry. Don't fail the whole publish for a - // commit hiccup. - process.stderr.write(`[vis release] Warning: could not commit workspace CHANGELOG.md: ${(error as Error).message}\n`); - } - } - } catch (error) { - // Soft-fail: workspace changelog is documentation, not - // a release blocker. - process.stderr.write(`[vis release] Warning: could not write workspace CHANGELOG.md wave entry: ${(error as Error).message}\n`); - } - } - } - - // Create GH releases (or aggregated release) for every successful publish. - // Post-publish lifecycle: forge releases + PR walker + chat - // notifications. All three run within the same guard (the publish - // actually shipped + tags reached the remote) but `noRelease` - // only suppresses the forge-release write — successWalk + chat - // notifications still fire even when the operator opts out of - // GH/GitLab release pages (e.g. teams managing release notes in - // a separate doc site still want PR comments + Slack pings). - // - // F3 fix (audit): `noRelease` previously gated this whole block, - // which silently disabled both successWalk and notifications. - // Now the inner block is split so each lifecycle step can be - // configured independently. - if (!options.dryRun && result.published.length > 0 && result.tagsPushed) { - if (context.config.publish?.noRelease !== true) { - await createRemoteReleases(context, result, runner, syncTags); - } - - // Walk every referenced PR / issue and post a "released in X.Y.Z" - // sticky comment + `released` label (semantic-release parity). - // Runs after createRemoteReleases so each entry has its release - // URL populated. Soft-fails everything to plan.warnings — the - // publish already succeeded, so a forge hiccup here shouldn't - // roll it back. - // - // C-4 fix: dedupe against `state.walked` so a `--resume` after a - // partial failure doesn't re-walk PRs whose comments + labels - // already landed on run 1. Sticky-comment markers provide - // forge-side idempotency, but skipping the walk entirely saves - // the rate-limited API calls (relevant for waves touching many - // PRs). - try { - const { walkSuccessfulRelease } = await import("../success-walk"); - const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); - const { resolveFormatter } = await import("../changelog/resolve"); - const provider = await detectRemoteProvider(context.cwd, runner, context.config.provider); - const client = createRemoteClient(provider, { - githubHost: context.config.githubHost, - gitlabHost: context.config.gitlabHost, - httpProxy: context.config.httpProxy, - }); - const repo = await client.detectRepoSlug(context.cwd, runner); - const formatter = await resolveFormatter(context.config.changelog, context.cwd); - - // Dedupe against BOTH `state.walked` (per-run dedupe — kept - // for `--resume` semantics on the originating runner) AND - // `stagedRegistry.recentlyWalked` (cross-runner dedupe — the - // committed `.vis/release/staged.json` survives a fresh CI - // runner checkout, so a re-fired workflow on a different - // machine doesn't re-walk the PRs). - const { recordRecentlyWalked } = await import("../staged-registry"); - const alreadyWalked = new Set([...(state.walked ?? []), ...(stagedRegistry.recentlyWalked ?? []).map((entry) => entry.key)]); - const walkable = { - ...result, - published: result.published.filter((p) => !alreadyWalked.has(`${p.name}@${p.version}`)), - }; - - if (walkable.published.length > 0) { - const walkResult = await walkSuccessfulRelease(context, walkable, runner, { - client, - formatter, - repo, - }); - - // Record successful walks so a later --resume doesn't repeat - // them. We optimistically mark the WHOLE walkable set as - // walked even if individual refs failed — the soft-fail - // logic inside walkSuccessfulRelease already turned those - // into warnings, so re-walking would just hit the same - // forge issues. - const walkedKeys = walkable.published.map((p) => `${p.name}@${p.version}`); - - state.walked = [...(state.walked ?? []), ...walkedKeys]; - await persistState(); - - // Cross-runner persistence — append to the tracked - // registry too. The follow-up registry write + commit - // sits after the notifications block so one commit - // captures both walked + notified deltas. - stagedRegistry = recordRecentlyWalked(stagedRegistry, walkedKeys); - - if (walkResult.warnings.length > 0) { - context.plan.warnings.push(...walkResult.warnings); - } - } - } catch (error) { - // Top-level safety net — any orchestration error in the walk - // setup itself (provider detect crash, formatter resolution) - // shouldn't fail the publish. - context.plan.warnings.push(`successWalk: could not run post-release walk: ${(error as Error).message}`); - } - - // Fan out post-release notifications (slack / discord / webhook / - // plugins). Runs LAST in the post-publish sequence because chat - // pings should reflect the FINAL state — tags pushed, GH releases - // created, PRs walked + labelled — not an in-progress snapshot. - // Per-channel failures are isolated; the publish itself is - // already complete and immutable at this point. - if (context.config.notifications) { - try { - const { dispatchNotifications } = await import("../notifications/interface"); - const { createRemoteClient, detectRemoteProvider } = await import("../remote/detect"); - const provider = await detectRemoteProvider(context.cwd, runner, context.config.provider); - const client = createRemoteClient(provider, { - githubHost: context.config.githubHost, - gitlabHost: context.config.gitlabHost, - httpProxy: context.config.httpProxy, - }); - const repo = await client.detectRepoSlug(context.cwd, runner); - - // Pull root package.json#name when available so chat - // messages carry the workspace identity even when the - // git remote slug isn't enough context. - let monorepoName: string | undefined; - - try { - const rootManifestPath = `${context.cwd}/package.json`; - const fs2 = await import("node:fs/promises"); - const rootManifest = JSON.parse(await fs2.readFile(rootManifestPath, "utf8")) as { name?: string }; - - monorepoName = rootManifest.name; - } catch { - // root manifest missing or unreadable — fine, we just - // omit the field. - } - - // C-4 fix: dedupe notifications against state.notified so - // a `--resume` after a partial failure doesn't re-fire - // Slack/Discord pings for releases that already shipped - // + notified on run 1. Unlike successWalk's sticky - // markers, chat webhooks have NO server-side idempotency - // — a re-send produces a duplicate message. - // - // Cross-runner extension: also dedupe against - // `stagedRegistry.recentlyNotified`, which is committed - // to the worktree and therefore survives a fresh CI - // runner clone. `state.notified` alone is per-run + per- - // machine — a re-fired workflow on a different runner - // would otherwise re-fire the chat pings. - const alreadyNotified = new Set([...(state.notified ?? []), ...(stagedRegistry.recentlyNotified ?? []).map((entry) => entry.key)]); - const notifiable = result.published.filter((p) => !alreadyNotified.has(`${p.name}@${p.version}`)); - - // When everything in this wave was already notified on a - // prior run, skip the dispatcher entirely (chat webhooks - // have no server-side idempotency, so a re-send would - // duplicate). Crucially, do NOT return here — the - // cross-runner persistence + post-publish hook below still - // need to run, and the function must return `result`. - if (notifiable.length > 0) { - const notificationResult = await dispatchNotifications( - context.config.notifications, - { - channel: context.channel?.tag, - completedAt: new Date().toISOString(), - ...(monorepoName === undefined ? {} : { monorepoName }), - published: notifiable.map((p) => { - return { - name: p.name, - tag: context.channel?.tag, - ...(p.url === undefined ? {} : { url: p.url }), - version: p.version, - }; - }), - ...(repo === undefined ? {} : { repo }), - skipped: result.skipped.map((s) => { - return { name: s.name, reason: s.reason }; - }), - }, - { warn: (m) => context.plan.warnings.push(m) }, - ); - - // Mark the dispatched-this-run set as notified ONLY when - // at least one channel succeeded — that way a total - // dispatch failure (all channels down) can retry next - // wave instead of being silently skipped forever. The - // sticky-marker / webhook-rate-limit guards keep the - // retry from being too noisy. - if (notificationResult.succeeded.length > 0) { - const notifiedKeys = notifiable.map((p) => `${p.name}@${p.version}`); - - state.notified = [...(state.notified ?? []), ...notifiedKeys]; - await persistState(); - - // Cross-runner persistence — same rationale as the - // walked path above. Mutates the in-memory registry; - // the trailing write + commit below captures it. - const { recordRecentlyNotified } = await import("../staged-registry"); - - stagedRegistry = recordRecentlyNotified(stagedRegistry, notifiedKeys); - } - - // B-2: surface the dispatch outcome explicitly so - // `vis release plan` and CI tails can show "notified - // 2/3 channels" instead of guessing from absent - // failure entries. Skipped when all channels succeed - // AND no failures occurred (the silent success case). - if (notificationResult.failed.length > 0 || notificationResult.succeeded.length > 0) { - const total = notificationResult.succeeded.length + notificationResult.failed.length; - - if (notificationResult.failed.length > 0) { - context.plan.warnings.push( - `[notifications] dispatched ${notificationResult.succeeded.length}/${total} channels${notificationResult.succeeded.length > 0 ? ` (succeeded: ${notificationResult.succeeded.join(", ")})` : ""}; ${notificationResult.failed.length} failed.`, - ); - - for (const failure of notificationResult.failed) { - context.plan.warnings.push(`[notifications:${failure.id}] ${failure.error}`); - } - } - } - } - } catch (error) { - // Top-level safety net for the dispatcher itself - // (config materialisation crash, dynamic-import failure). - context.plan.warnings.push(`notifications: dispatch failed: ${(error as Error).message}`); - } - } - - // Cross-runner persistence — commit staged.json IF either - // recentlyNotified or recentlyWalked grew during this wave. - // The initial write at the end of the publish loop only - // captured the pending-stage delta; the walk + notify steps - // run AFTER that point, so a second write picks up their - // contributions. - // - // Soft-failed for the same reason the first commit is: - // staged.json being on disk is enough to recover next wave, - // even if the commit itself hiccupped. - if (!options.dryRun) { - try { - const followUpWrite = await writeStagedRegistry(context.cwd, changesDir, stagedRegistry); - - if (followUpWrite.changed) { - const { stageAndCommitFile } = await import("../git"); - - try { - await stageAndCommitFile( - { cwd: context.cwd, runner }, - followUpWrite.path, - "chore(release): record cross-runner notify/walk dedupe [skip ci]", - { - author: context.config.gitUser, - push: !options.noPush, - sign: context.config.gitSignCommits === true, - }, - ); - } catch (error) { - process.stderr.write( - `[vis release] Warning: could not commit ${followUpWrite.path} (notify/walk dedupe): ${(error as Error).message}\n`, - ); - } - } - } catch (error) { - // Don't fail the whole publish for a registry-write - // hiccup — the cross-runner dedupe degrades gracefully - // back to `state.notified` / `state.walked` (per-run - // only) on the next wave. - process.stderr.write(`[vis release] Warning: could not update cross-runner dedupe registry: ${(error as Error).message}\n`); - } - } - } + await commitStagedRegistry(wave); + await writeChangelogWave(wave); + await runPostPublish(wave, syncTags); if (!options.dryRun && context.config.postPublishCommand) { try { diff --git a/packages/tooling/vis/src/release/core/security.ts b/packages/tooling/vis/src/release/core/security.ts index 12fa4da6..11d07c60 100644 --- a/packages/tooling/vis/src/release/core/security.ts +++ b/packages/tooling/vis/src/release/core/security.ts @@ -8,6 +8,7 @@ import zeptomatch from "zeptomatch"; +import { shellQuote } from "../../util/shell-quote"; import { VisReleaseError } from "../errors"; import type { PerPackageReleaseConfig, VisReleaseConfig } from "../types"; @@ -49,43 +50,9 @@ export const assertValidPackageName = (name: string): void => { } }; -// ── sq(): POSIX single-quote shell escape ────────────────────────── +// ── Shell quoting lives in util/shell-quote; re-exported here for release callers ── -/** - * Wrap a string in POSIX single quotes, escaping embedded single quotes. - * Safe to interpolate into `sh -c <cmd>` lines without injection risk. - * Port of bumpy's `sq()` helper (RFC §19.4). - * @example - * sq("hello") // returns "'hello'" - * sq("it's fine") // returns "'it'\\''s fine'" - * sq("$(rm -rf /)") // returns "'$(rm -rf /)'" - */ -export const sq = (value: string): string => `'${value.replaceAll("'", String.raw`'\''`)}'`; - -/** - * Windows `cmd.exe` analogue of {@link sq}. `cmd.exe` does not treat single - * quotes as quoting characters, so `sq()` gives no protection there — a value - * like a registry URL containing shell metacharacters would break out of its - * argument slot. Wrapping in double quotes makes those metacharacters (pipe, - * redirection, caret, parentheses, and whitespace) literal; embedded double - * quotes are doubled per the cmd convention. - * - * Caveat: `%VAR%` / delayed-`!VAR!` expansion still happens inside double - * quotes. That residual is acceptable because custom commands are gated behind - * `allowCustomCommands`, and the substituted token values are the maintainer's - * own (package name — charset-validated — version, tag, registry). - * @example - * cmdq("hello") // returns '"hello"' - * cmdq('say "hi"') // returns '"say ""hi"""' - */ -export const cmdq = (value: string): string => `"${value.replaceAll(`"`, `""`)}"`; - -/** - * Quote a token value for whichever shell will run the command — `sh -c` on - * POSIX, `cmd /c` on Windows. The orchestrator and shell action both pick the - * shell from `process.platform`, so the quoting must match that decision. - */ -export const shellQuote = (value: string, isWindows: boolean = process.platform === "win32"): string => (isWindows ? cmdq(value) : sq(value)); +export { cmdq, shellQuote, sq } from "../../util/shell-quote"; // ── Custom-command trust gate ────────────────────────────────────── diff --git a/packages/tooling/vis/src/release/core/state.ts b/packages/tooling/vis/src/release/core/state.ts index da76e8eb..2afe570a 100644 --- a/packages/tooling/vis/src/release/core/state.ts +++ b/packages/tooling/vis/src/release/core/state.ts @@ -21,6 +21,7 @@ import { mkdir, open, readFile, unlink, writeFile } from "node:fs/promises"; import { hostname, platform } from "node:os"; import { dirname, join } from "node:path"; +import { isPidAlive } from "../../util/process-alive"; import { VisReleaseError } from "../errors"; import type { LockInfo, PlannedRelease, StateFile } from "../types"; @@ -125,18 +126,6 @@ const STALE_LOCK_MS = 60 * 60 * 1000; // 1 hour export const lockFilePath = (cwd: string, changesDir: string): string => join(cwd, changesDir, ".lock"); -const isPidAlive = (pid: number): boolean => { - try { - // Signal 0 doesn't actually send a signal — it just checks whether - // the target PID exists and is reachable. - process.kill(pid, 0); - - return true; - } catch { - return false; - } -}; - /** * Try to acquire the process-level lock. Returns the path on success; * throws `STATE_FILE_CORRUPT` (overloaded for lock issues) when: diff --git a/packages/tooling/vis/src/services/registry.ts b/packages/tooling/vis/src/services/registry.ts index 28fe897d..22c13495 100644 --- a/packages/tooling/vis/src/services/registry.ts +++ b/packages/tooling/vis/src/services/registry.ts @@ -4,6 +4,7 @@ import { open, readdir, readFile, rename, rm, stat, unlink, writeFile } from "no import { ensureDir, isAccessible, readJson } from "@visulima/fs"; import { join } from "@visulima/path"; +import { isPidAlive } from "../util/process-alive"; import { getVisWorkspaceDir } from "../util/vis-paths"; import type { ServiceEntry } from "./types"; @@ -218,15 +219,7 @@ export const deleteEntry = async (workspaceRoot: string, id: string, entry?: Ser * Auto-attaching to a stranger is worse than missing a service we do * own — the operator can always restart it. */ -export const isAlive = (pid: number): boolean => { - try { - process.kill(pid, 0); - - return true; - } catch { - return false; - } -}; +export const isAlive = isPidAlive; /** * Result of `pruneDead`: which entry ids were pruned, plus the surviving diff --git a/packages/tooling/vis/src/task/target-options.ts b/packages/tooling/vis/src/task/target-options.ts index 2bb1c156..e48e1700 100644 --- a/packages/tooling/vis/src/task/target-options.ts +++ b/packages/tooling/vis/src/task/target-options.ts @@ -2,6 +2,7 @@ import { platform } from "node:os"; import { isAccessibleSync, readFileSync } from "@visulima/fs"; import { isAbsolute, join } from "@visulima/path"; +import type { Task } from "@visulima/task-runner"; import type { TargetOsType, TargetPreset, TargetType, VisTargetConfiguration, VisTargetOptions } from "./types"; @@ -53,6 +54,13 @@ export const applyPreset = (target: VisTargetConfiguration): VisTargetConfigurat }; /** Detects the current operating system as a {@link TargetOsType}. */ +/** The `visOptions` override a task carries, when it is an object. */ +export const getVisOptions = (task: Task): VisTargetOptions | undefined => { + const options = task.overrides["visOptions"]; + + return options && typeof options === "object" ? options : undefined; +}; + export const detectCurrentOs = (): TargetOsType => { const p = platform(); diff --git a/packages/tooling/vis/src/util/catalog.ts b/packages/tooling/vis/src/util/catalog.ts index 96079647..1db146e2 100644 --- a/packages/tooling/vis/src/util/catalog.ts +++ b/packages/tooling/vis/src/util/catalog.ts @@ -1,540 +1,10 @@ -import { isAccessibleSync, readJsonSync, writeJsonSync } from "@visulima/fs"; -import { join } from "@visulima/path"; - -import { readPnpmWorkspacePatterns, resolveWorkspacePatterns } from "../config/workspace"; -import { createBackup } from "./catalog/backup"; -import { applyBunCatalogUpdates, hasBunCatalogs, readBunCatalogs } from "./catalog/bun"; -import { DEFAULT_DEP_TYPES, parseCompositeCatalogName } from "./catalog/composite"; -import { matchesFilters, matchesPattern, resolvePackageTarget } from "./catalog/patterns"; -import { applyPnpmCatalogUpdates } from "./catalog/pnpm-updates"; -import { hasPnpmCatalogs, readPnpmCatalogs } from "./catalog/pnpm-yaml"; -import type { ApplyCatalogUpdatesOptions, InternalOutdatedOptions, OutdatedEntry, ReadCatalogOptions, UpdateTarget } from "./catalog/types"; -import { extractPrefix, getUpdateType, isNewer, parseVersion } from "./catalog/version"; -import { resolveIndentForExistingFile } from "./editorconfig"; - -const getDepTypesToInclude = (options?: ReadCatalogOptions): string[] => { - if (options?.dev) { - return ["devDependencies"]; - } - - if (options?.prod) { - return ["dependencies"]; - } - - if (options?.depFields && options.depFields.length > 0) { - return options.depFields; - } - - // peerDependencies are opt-in: bumping a peer is a behavior change for - // every consumer of the package, so the user has to ask for it via - // `--peer` rather than getting it silently merged into every update run. - return options?.peer ? [...DEFAULT_DEP_TYPES, "peerDependencies"] : DEFAULT_DEP_TYPES; -}; - -const collectInternalPackageNames = (workspaceRoot: string, rootName: string | undefined, workspaceDirectories: string[]): Set => { - const names = new Set(); - - if (rootName) { - names.add(rootName); - } - - for (const directory of workspaceDirectories) { - const pkgPath = join(workspaceRoot, directory, "package.json"); - - if (isAccessibleSync(pkgPath)) { - try { - const pkg = readJsonSync(pkgPath) as { name?: string }; - - if (pkg.name) { - names.add(pkg.name); - } - } catch { - // skip invalid package.json - } - } - } - - return names; -}; - -const getNestedField = (object: Record, path: string): Record | undefined => { - const parts = path.split("."); - let current: unknown = object; - - for (const part of parts) { - if (current && typeof current === "object") { - current = (current as Record)[part]; - } else { - return undefined; - } - } - - return typeof current === "object" && current !== null ? (current as Record) : undefined; -}; - -const setNestedField = (object: Record, path: string, key: string, value: string): void => { - const parts = path.split("."); - let current: Record = object; - - for (const part of parts.slice(0, -1)) { - if (!current[part] || typeof current[part] !== "object") { - current[part] = {}; - } - - current = current[part] as Record; - } - - const lastPart = parts.at(-1)!; - - if (!current[lastPart] || typeof current[lastPart] !== "object") { - current[lastPart] = {}; - } - - (current[lastPart] as Record)[key] = value; -}; - -const filterExternalDeps = (deps: Record, internalNames: Set): Map => { - const filtered = new Map(); - - for (const [name, range] of Object.entries(deps)) { - if (internalNames.has(name)) { - continue; - } - - if (range.startsWith("workspace:") || range.startsWith("file:") || range.startsWith("link:") || range.startsWith("catalog:") || range.startsWith("$")) { - continue; - } - - filtered.set(name, range); - } - - return filtered; -}; - -const scanDirectoryDeps = ( - workspaceRoot: string, - directory: string, - rootPkgPath: string, - depTypes: string[], - internalNames: Set, - catalogs: Map>, -): void => { - const pkgPath = directory === "." ? rootPkgPath : join(workspaceRoot, directory, "package.json"); - - if (!isAccessibleSync(pkgPath)) { - return; - } - - let pkg: Record; - - try { - pkg = readJsonSync(pkgPath) as Record; - } catch { - return; - } - - for (const depType of depTypes) { - const deps = depType.includes(".") ? getNestedField(pkg, depType) : (pkg[depType] as Record | undefined); - - if (!deps || typeof deps !== "object") { - continue; - } - - const filtered = filterExternalDeps(deps, internalNames); - - if (filtered.size > 0) { - catalogs.set(`${directory}:${depType}`, filtered); - } - } -}; - -const readPackageJsonDeps = (workspaceRoot: string, options?: ReadCatalogOptions): Map> => { - const catalogs = new Map>(); - const rootPkgPath = join(workspaceRoot, "package.json"); - - if (!isAccessibleSync(rootPkgPath)) { - return catalogs; - } - - const rootPkg = readJsonSync(rootPkgPath) as { - dependencies?: Record; - devDependencies?: Record; - name?: string; - optionalDependencies?: Record; - peerDependencies?: Record; - workspaces?: string[] | { catalog?: Record; packages?: string[] }; - }; - - // Prefer pnpm-workspace.yaml when present — pnpm exclusions (`!dir/**`) - // only live there, and reading package.json#workspaces first would lose - // them for monorepos that ship both files (the npm field is kept for - // editor / Yarn Berry / npm-classic compatibility, not as the source of - // truth). Mirrors the order used in sort-package-json/handler.ts. - let workspaceDirectories: string[] = []; - const pnpmPatterns = readPnpmWorkspacePatterns(workspaceRoot); - - if (pnpmPatterns) { - workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, pnpmPatterns); - } else { - const workspacesField = rootPkg.workspaces; - - if (workspacesField) { - const patterns = Array.isArray(workspacesField) ? workspacesField : workspacesField.packages; - - if (patterns) { - workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, patterns); - } - } - } - - // When `--include-internal` is on, treat workspace-owned names as if - // they were external — the registry pass will then check them like any - // other dep. The non-protocol filter inside `filterExternalDeps` - // (workspace:/file:/link:/catalog:) still applies, so live workspace - // links don't leak in. - const internalNames = options?.includeInternal ? new Set() : collectInternalPackageNames(workspaceRoot, rootPkg.name, workspaceDirectories); - const depTypes = getDepTypesToInclude(options); - const directoriesToScan = [".", ...workspaceDirectories]; - - for (const directory of directoriesToScan) { - scanDirectoryDeps(workspaceRoot, directory, rootPkgPath, depTypes, internalNames, catalogs); - } - - return catalogs; -}; - -const hasPackageJsonDeps = (workspaceRoot: string): boolean => { - const pkgPath = join(workspaceRoot, "package.json"); - - if (!isAccessibleSync(pkgPath)) { - return false; - } - - try { - const pkg = readJsonSync(pkgPath) as Record; - - return !!( - pkg.dependencies - || pkg.devDependencies - || pkg.peerDependencies - || pkg.optionalDependencies - || pkg.overrides - || pkg.resolutions - || getNestedField(pkg, "pnpm.overrides") - ); - } catch { - return false; - } -}; - -const applyPackageJsonUpdates = (workspaceRoot: string, updates: OutdatedEntry[], useEditorconfig?: boolean): void => { - const byFile = new Map(); - - for (const update of updates) { - const parsed = parseCompositeCatalogName(update.catalogName); - - if (!parsed) { - continue; - } - - const filePath = parsed.relativePath === "." ? join(workspaceRoot, "package.json") : join(workspaceRoot, parsed.relativePath, "package.json"); - - if (!byFile.has(filePath)) { - byFile.set(filePath, []); - } - - const fileList = byFile.get(filePath); - - if (fileList) { - fileList.push({ depType: parsed.depType, newRange: update.newRange, packageName: update.packageName }); - } - } - - for (const [filePath, fileUpdates] of byFile) { - const pkg = readJsonSync(filePath) as Record>; - - for (const { depType, newRange, packageName } of fileUpdates) { - if (depType.includes(".")) { - setNestedField(pkg, depType, packageName, newRange); - } else if (pkg[depType]) { - pkg[depType][packageName] = newRange; - } - } - - writeJsonSync(filePath, pkg, { indent: resolveIndentForExistingFile(filePath, { useEditorconfig }), overwrite: true }); - } -}; - -const hasCatalogs = (workspaceRoot: string, packageManager?: string): boolean => { - if (packageManager === "bun") { - return hasBunCatalogs(workspaceRoot) || hasPackageJsonDeps(workspaceRoot); - } - - if (packageManager === "npm" || packageManager === "yarn") { - return hasPackageJsonDeps(workspaceRoot); - } - - // pnpm: try catalogs first, fall back to package.json deps - return hasPnpmCatalogs(workspaceRoot) || hasPackageJsonDeps(workspaceRoot); -}; - -const readCatalogs = (workspaceRoot: string, packageManager?: string, options?: ReadCatalogOptions): Map> => { - let catalogs: Map>; - - if (packageManager === "bun") { - catalogs = readBunCatalogs(workspaceRoot); - } else if (packageManager === "npm" || packageManager === "yarn") { - catalogs = new Map(); - } else { - // pnpm - catalogs = readPnpmCatalogs(workspaceRoot); - } - - // Always merge in non-catalog package.json deps (direct version ranges) - const pkgDeps = readPackageJsonDeps(workspaceRoot, options); - - for (const [key, deps] of pkgDeps) { - if (!catalogs.has(key)) { - catalogs.set(key, deps); - } - } - - return catalogs; -}; - -const collectInternalPackageVersions = (workspaceRoot: string, workspaceDirectories: string[]): Map => { - const versions = new Map(); - - for (const directory of workspaceDirectories) { - const pkgPath = join(workspaceRoot, directory, "package.json"); - - if (!isAccessibleSync(pkgPath)) { - continue; - } - - try { - const pkg = readJsonSync(pkgPath) as { name?: string; version?: string }; - - if (pkg.name && pkg.version) { - versions.set(pkg.name, pkg.version); - } - } catch { - // skip invalid package.json - } - } - - return versions; -}; - -/** - * Find workspace-internal dependencies whose declared version range lags - * behind the latest local workspace version. Used by `vis update` to bump - * cross-package version pins (e.g. `"@visulima/fs": "5.0.0-alpha.13"`) to - * match a freshly-released local version without waiting for the npm - * registry to reflect the publish. - * - * Skips deps using non-version protocols (`workspace:`, `file:`, `link:`, - * `catalog:`, `*`) since those resolve dynamically. Returns - * {@link OutdatedEntry}s with composite catalog names (`{path}:{depType}`) - * so {@link applyCatalogUpdates} routes them to per-package writes via - * {@link applyPackageJsonUpdates}. - */ -const collectInternalOutdated = (workspaceRoot: string, options?: InternalOutdatedOptions): { ignored: string[]; outdated: OutdatedEntry[] } => { - const rootPkgPath = join(workspaceRoot, "package.json"); - - if (!isAccessibleSync(rootPkgPath)) { - return { ignored: [], outdated: [] }; - } - - const rootPkg = readJsonSync(rootPkgPath) as { - name?: string; - workspaces?: string[] | { packages?: string[] }; - }; - - let workspaceDirectories: string[] = []; - const pnpmPatterns = readPnpmWorkspacePatterns(workspaceRoot); - - if (pnpmPatterns) { - workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, pnpmPatterns); - } else { - const workspacesField = rootPkg.workspaces; - - if (workspacesField) { - const patterns = Array.isArray(workspacesField) ? workspacesField : workspacesField.packages; - - if (patterns) { - workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, patterns); - } - } - } - - const internalVersions = collectInternalPackageVersions(workspaceRoot, workspaceDirectories); - - if (internalVersions.size === 0) { - return { ignored: [], outdated: [] }; - } - - const depTypes = getDepTypesToInclude(options); - const directoriesToScan = [".", ...workspaceDirectories]; - const include = options?.include ?? []; - const exclude = options?.exclude ?? []; - const ignore = options?.ignore ?? []; - const target: UpdateTarget = options?.target ?? "latest"; - const ignoredSet = new Set(); - const outdated: OutdatedEntry[] = []; - - for (const directory of directoriesToScan) { - const pkgPath = directory === "." ? rootPkgPath : join(workspaceRoot, directory, "package.json"); - - if (!isAccessibleSync(pkgPath)) { - continue; - } - - let pkg: Record; - - try { - pkg = readJsonSync(pkgPath) as Record; - } catch { - continue; - } - - for (const depType of depTypes) { - const deps = depType.includes(".") ? getNestedField(pkg, depType) : (pkg[depType] as Record | undefined); - - if (!deps || typeof deps !== "object") { - continue; - } - - for (const [name, range] of Object.entries(deps)) { - const targetVersion = internalVersions.get(name); - - if (!targetVersion) { - continue; - } - - if ( - range.startsWith("workspace:") - || range.startsWith("file:") - || range.startsWith("link:") - || range.startsWith("catalog:") - || range.startsWith("$") - || range === "*" - ) { - continue; - } - - if (ignore.some((p) => matchesPattern(name, p))) { - ignoredSet.add(name); - continue; - } - - if (!matchesFilters(name, include, exclude)) { - continue; - } - - const currentParsed = parseVersion(range); - const targetParsed = parseVersion(targetVersion); - - if (!currentParsed || !targetParsed) { - continue; - } - - if (!isNewer(currentParsed, targetParsed)) { - continue; - } - - const updateType = getUpdateType(currentParsed, targetParsed); - - if (updateType === "none") { - continue; - } - - const effectiveTarget = resolvePackageTarget(name, target, options?.packageMode); - - if (effectiveTarget === "patch" && updateType !== "patch") { - continue; - } - - if (effectiveTarget === "minor" && updateType === "major") { - continue; - } - - const prefix = extractPrefix(range); - - outdated.push({ - catalogName: `${directory}:${depType}`, - currentRange: range, - newRange: `${prefix}${targetVersion}`, - packageName: name, - targetVersion, - updateType, - }); - } - } - } - - return { ignored: [...ignoredSet], outdated }; -}; - -const toFilterArray = (value: string | string[] | undefined): string[] => { - if (!value) { - return []; - } - - return Array.isArray(value) ? value : [value]; -}; - -const applyCatalogUpdates = ( - workspaceRoot: string, - updates: OutdatedEntry[], - packageManager?: string, - backup = true, - options: ApplyCatalogUpdatesOptions = {}, -): string | undefined => { - const { useEditorconfig } = options; - let backupPath: string | undefined; - - if (backup) { - backupPath = createBackup(workspaceRoot, packageManager, updates); - } - - // Split updates into catalog entries and direct package.json entries - const catalogUpdates: OutdatedEntry[] = []; - const pkgJsonUpdates: OutdatedEntry[] = []; - - for (const update of updates) { - if (parseCompositeCatalogName(update.catalogName)) { - pkgJsonUpdates.push(update); - } else { - catalogUpdates.push(update); - } - } - - // Apply catalog updates via the appropriate provider - if (catalogUpdates.length > 0) { - if (packageManager === "npm" || packageManager === "yarn") { - applyPackageJsonUpdates(workspaceRoot, catalogUpdates, useEditorconfig); - } else if (packageManager === "bun") { - applyBunCatalogUpdates(workspaceRoot, catalogUpdates, useEditorconfig); - } else { - applyPnpmCatalogUpdates(workspaceRoot, catalogUpdates); - } - } - - // Apply direct package.json version updates - if (pkgJsonUpdates.length > 0) { - applyPackageJsonUpdates(workspaceRoot, pkgJsonUpdates, useEditorconfig); - } - - return backupPath; -}; - -export { applyCatalogUpdates, applyPackageJsonUpdates, collectInternalOutdated, hasCatalogs, hasPackageJsonDeps, readCatalogs, readPackageJsonDeps, toFilterArray }; - export { createBackup, hasBackup, restoreFromBackup } from "./catalog/backup"; export { detectJsonIndent, parseBunCatalogs } from "./catalog/bun"; export { fetchChangelogInfo } from "./catalog/changelog"; export { parseCompositeCatalogName } from "./catalog/composite"; +// The catalog implementation lives in ./catalog/engine.ts. This file keeps the +// public surface of `util/catalog` unchanged. +export { applyCatalogUpdates, applyPackageJsonUpdates, collectInternalOutdated, hasCatalogs, hasPackageJsonDeps, readCatalogs, readPackageJsonDeps, toFilterArray } from "./catalog/engine"; export { formatOutdatedJson, formatOutdatedMinimal, formatOutdatedTable, formatSummary, groupByCatalog } from "./catalog/format"; export { getRegistryForPackage, loadNpmrc, parseNpmrc } from "./catalog/npmrc"; export { fetchVulnerabilities } from "./catalog/osv"; diff --git a/packages/tooling/vis/src/util/catalog/engine.ts b/packages/tooling/vis/src/util/catalog/engine.ts new file mode 100644 index 00000000..e08ed23a --- /dev/null +++ b/packages/tooling/vis/src/util/catalog/engine.ts @@ -0,0 +1,512 @@ +import { isAccessibleSync, readJsonSync, writeJsonSync } from "@visulima/fs"; +import { join } from "@visulima/path"; + +import { readPnpmWorkspacePatterns, resolveWorkspacePatterns } from "../../config/workspace"; +import { resolveIndentForExistingFile } from "../editorconfig"; +import { setNestedField } from "../nested-field"; +import { createBackup } from "./backup"; +import { applyBunCatalogUpdates, hasBunCatalogs, readBunCatalogs } from "./bun"; +import { DEFAULT_DEP_TYPES, parseCompositeCatalogName } from "./composite"; +import { matchesFilters, matchesPattern, resolvePackageTarget } from "./patterns"; +import { applyPnpmCatalogUpdates } from "./pnpm-updates"; +import { hasPnpmCatalogs, readPnpmCatalogs } from "./pnpm-yaml"; +import type { ApplyCatalogUpdatesOptions, InternalOutdatedOptions, OutdatedEntry, ReadCatalogOptions, UpdateTarget } from "./types"; +import { extractPrefix, getUpdateType, isNewer, parseVersion } from "./version"; + +const getDepTypesToInclude = (options?: ReadCatalogOptions): string[] => { + if (options?.dev) { + return ["devDependencies"]; + } + + if (options?.prod) { + return ["dependencies"]; + } + + if (options?.depFields && options.depFields.length > 0) { + return options.depFields; + } + + // peerDependencies are opt-in: bumping a peer is a behavior change for + // every consumer of the package, so the user has to ask for it via + // `--peer` rather than getting it silently merged into every update run. + return options?.peer ? [...DEFAULT_DEP_TYPES, "peerDependencies"] : DEFAULT_DEP_TYPES; +}; + +const collectInternalPackageNames = (workspaceRoot: string, rootName: string | undefined, workspaceDirectories: string[]): Set => { + const names = new Set(); + + if (rootName) { + names.add(rootName); + } + + for (const directory of workspaceDirectories) { + const pkgPath = join(workspaceRoot, directory, "package.json"); + + if (isAccessibleSync(pkgPath)) { + try { + const pkg = readJsonSync(pkgPath) as { name?: string }; + + if (pkg.name) { + names.add(pkg.name); + } + } catch { + // skip invalid package.json + } + } + } + + return names; +}; + +const getNestedField = (object: Record, path: string): Record | undefined => { + const parts = path.split("."); + let current: unknown = object; + + for (const part of parts) { + if (current && typeof current === "object") { + current = (current as Record)[part]; + } else { + return undefined; + } + } + + return typeof current === "object" && current !== null ? (current as Record) : undefined; +}; + +const filterExternalDeps = (deps: Record, internalNames: Set): Map => { + const filtered = new Map(); + + for (const [name, range] of Object.entries(deps)) { + if (internalNames.has(name)) { + continue; + } + + if (range.startsWith("workspace:") || range.startsWith("file:") || range.startsWith("link:") || range.startsWith("catalog:") || range.startsWith("$")) { + continue; + } + + filtered.set(name, range); + } + + return filtered; +}; + +const scanDirectoryDeps = ( + workspaceRoot: string, + directory: string, + rootPkgPath: string, + depTypes: string[], + internalNames: Set, + catalogs: Map>, +): void => { + const pkgPath = directory === "." ? rootPkgPath : join(workspaceRoot, directory, "package.json"); + + if (!isAccessibleSync(pkgPath)) { + return; + } + + let pkg: Record; + + try { + pkg = readJsonSync(pkgPath) as Record; + } catch { + return; + } + + for (const depType of depTypes) { + const deps = depType.includes(".") ? getNestedField(pkg, depType) : (pkg[depType] as Record | undefined); + + if (!deps || typeof deps !== "object") { + continue; + } + + const filtered = filterExternalDeps(deps, internalNames); + + if (filtered.size > 0) { + catalogs.set(`${directory}:${depType}`, filtered); + } + } +}; + +const readPackageJsonDeps = (workspaceRoot: string, options?: ReadCatalogOptions): Map> => { + const catalogs = new Map>(); + const rootPkgPath = join(workspaceRoot, "package.json"); + + if (!isAccessibleSync(rootPkgPath)) { + return catalogs; + } + + const rootPkg = readJsonSync(rootPkgPath) as { + dependencies?: Record; + devDependencies?: Record; + name?: string; + optionalDependencies?: Record; + peerDependencies?: Record; + workspaces?: string[] | { catalog?: Record; packages?: string[] }; + }; + + // Prefer pnpm-workspace.yaml when present — pnpm exclusions (`!dir/**`) + // only live there, and reading package.json#workspaces first would lose + // them for monorepos that ship both files (the npm field is kept for + // editor / Yarn Berry / npm-classic compatibility, not as the source of + // truth). Mirrors the order used in sort-package-json/handler.ts. + let workspaceDirectories: string[] = []; + const pnpmPatterns = readPnpmWorkspacePatterns(workspaceRoot); + + if (pnpmPatterns) { + workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, pnpmPatterns); + } else { + const workspacesField = rootPkg.workspaces; + + if (workspacesField) { + const patterns = Array.isArray(workspacesField) ? workspacesField : workspacesField.packages; + + if (patterns) { + workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, patterns); + } + } + } + + // When `--include-internal` is on, treat workspace-owned names as if + // they were external — the registry pass will then check them like any + // other dep. The non-protocol filter inside `filterExternalDeps` + // (workspace:/file:/link:/catalog:) still applies, so live workspace + // links don't leak in. + const internalNames = options?.includeInternal ? new Set() : collectInternalPackageNames(workspaceRoot, rootPkg.name, workspaceDirectories); + const depTypes = getDepTypesToInclude(options); + const directoriesToScan = [".", ...workspaceDirectories]; + + for (const directory of directoriesToScan) { + scanDirectoryDeps(workspaceRoot, directory, rootPkgPath, depTypes, internalNames, catalogs); + } + + return catalogs; +}; + +const hasPackageJsonDeps = (workspaceRoot: string): boolean => { + const pkgPath = join(workspaceRoot, "package.json"); + + if (!isAccessibleSync(pkgPath)) { + return false; + } + + try { + const pkg = readJsonSync(pkgPath) as Record; + + return !!( + pkg.dependencies + || pkg.devDependencies + || pkg.peerDependencies + || pkg.optionalDependencies + || pkg.overrides + || pkg.resolutions + || getNestedField(pkg, "pnpm.overrides") + ); + } catch { + return false; + } +}; + +const applyPackageJsonUpdates = (workspaceRoot: string, updates: OutdatedEntry[], useEditorconfig?: boolean): void => { + const byFile = new Map(); + + for (const update of updates) { + const parsed = parseCompositeCatalogName(update.catalogName); + + if (!parsed) { + continue; + } + + const filePath = parsed.relativePath === "." ? join(workspaceRoot, "package.json") : join(workspaceRoot, parsed.relativePath, "package.json"); + + if (!byFile.has(filePath)) { + byFile.set(filePath, []); + } + + const fileList = byFile.get(filePath); + + if (fileList) { + fileList.push({ depType: parsed.depType, newRange: update.newRange, packageName: update.packageName }); + } + } + + for (const [filePath, fileUpdates] of byFile) { + const pkg = readJsonSync(filePath) as Record>; + + for (const { depType, newRange, packageName } of fileUpdates) { + if (depType.includes(".")) { + setNestedField(pkg, depType, packageName, newRange); + } else if (pkg[depType]) { + pkg[depType][packageName] = newRange; + } + } + + writeJsonSync(filePath, pkg, { indent: resolveIndentForExistingFile(filePath, { useEditorconfig }), overwrite: true }); + } +}; + +const hasCatalogs = (workspaceRoot: string, packageManager?: string): boolean => { + if (packageManager === "bun") { + return hasBunCatalogs(workspaceRoot) || hasPackageJsonDeps(workspaceRoot); + } + + if (packageManager === "npm" || packageManager === "yarn") { + return hasPackageJsonDeps(workspaceRoot); + } + + // pnpm: try catalogs first, fall back to package.json deps + return hasPnpmCatalogs(workspaceRoot) || hasPackageJsonDeps(workspaceRoot); +}; + +const readCatalogs = (workspaceRoot: string, packageManager?: string, options?: ReadCatalogOptions): Map> => { + let catalogs: Map>; + + if (packageManager === "bun") { + catalogs = readBunCatalogs(workspaceRoot); + } else if (packageManager === "npm" || packageManager === "yarn") { + catalogs = new Map(); + } else { + // pnpm + catalogs = readPnpmCatalogs(workspaceRoot); + } + + // Always merge in non-catalog package.json deps (direct version ranges) + const pkgDeps = readPackageJsonDeps(workspaceRoot, options); + + for (const [key, deps] of pkgDeps) { + if (!catalogs.has(key)) { + catalogs.set(key, deps); + } + } + + return catalogs; +}; + +const collectInternalPackageVersions = (workspaceRoot: string, workspaceDirectories: string[]): Map => { + const versions = new Map(); + + for (const directory of workspaceDirectories) { + const pkgPath = join(workspaceRoot, directory, "package.json"); + + if (!isAccessibleSync(pkgPath)) { + continue; + } + + try { + const pkg = readJsonSync(pkgPath) as { name?: string; version?: string }; + + if (pkg.name && pkg.version) { + versions.set(pkg.name, pkg.version); + } + } catch { + // skip invalid package.json + } + } + + return versions; +}; + +/** + * Find workspace-internal dependencies whose declared version range lags + * behind the latest local workspace version. Used by `vis update` to bump + * cross-package version pins (e.g. `"@visulima/fs": "5.0.0-alpha.13"`) to + * match a freshly-released local version without waiting for the npm + * registry to reflect the publish. + * + * Skips deps using non-version protocols (`workspace:`, `file:`, `link:`, + * `catalog:`, `*`) since those resolve dynamically. Returns + * {@link OutdatedEntry}s with composite catalog names (`{path}:{depType}`) + * so {@link applyCatalogUpdates} routes them to per-package writes via + * {@link applyPackageJsonUpdates}. + */ +const collectInternalOutdated = (workspaceRoot: string, options?: InternalOutdatedOptions): { ignored: string[]; outdated: OutdatedEntry[] } => { + const rootPkgPath = join(workspaceRoot, "package.json"); + + if (!isAccessibleSync(rootPkgPath)) { + return { ignored: [], outdated: [] }; + } + + const rootPkg = readJsonSync(rootPkgPath) as { + name?: string; + workspaces?: string[] | { packages?: string[] }; + }; + + let workspaceDirectories: string[] = []; + const pnpmPatterns = readPnpmWorkspacePatterns(workspaceRoot); + + if (pnpmPatterns) { + workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, pnpmPatterns); + } else { + const workspacesField = rootPkg.workspaces; + + if (workspacesField) { + const patterns = Array.isArray(workspacesField) ? workspacesField : workspacesField.packages; + + if (patterns) { + workspaceDirectories = resolveWorkspacePatterns(workspaceRoot, patterns); + } + } + } + + const internalVersions = collectInternalPackageVersions(workspaceRoot, workspaceDirectories); + + if (internalVersions.size === 0) { + return { ignored: [], outdated: [] }; + } + + const depTypes = getDepTypesToInclude(options); + const directoriesToScan = [".", ...workspaceDirectories]; + const include = options?.include ?? []; + const exclude = options?.exclude ?? []; + const ignore = options?.ignore ?? []; + const target: UpdateTarget = options?.target ?? "latest"; + const ignoredSet = new Set(); + const outdated: OutdatedEntry[] = []; + + for (const directory of directoriesToScan) { + const pkgPath = directory === "." ? rootPkgPath : join(workspaceRoot, directory, "package.json"); + + if (!isAccessibleSync(pkgPath)) { + continue; + } + + let pkg: Record; + + try { + pkg = readJsonSync(pkgPath) as Record; + } catch { + continue; + } + + for (const depType of depTypes) { + const deps = depType.includes(".") ? getNestedField(pkg, depType) : (pkg[depType] as Record | undefined); + + if (!deps || typeof deps !== "object") { + continue; + } + + for (const [name, range] of Object.entries(deps)) { + const targetVersion = internalVersions.get(name); + + if (!targetVersion) { + continue; + } + + if ( + range.startsWith("workspace:") + || range.startsWith("file:") + || range.startsWith("link:") + || range.startsWith("catalog:") + || range.startsWith("$") + || range === "*" + ) { + continue; + } + + if (ignore.some((p) => matchesPattern(name, p))) { + ignoredSet.add(name); + continue; + } + + if (!matchesFilters(name, include, exclude)) { + continue; + } + + const currentParsed = parseVersion(range); + const targetParsed = parseVersion(targetVersion); + + if (!currentParsed || !targetParsed) { + continue; + } + + if (!isNewer(currentParsed, targetParsed)) { + continue; + } + + const updateType = getUpdateType(currentParsed, targetParsed); + + if (updateType === "none") { + continue; + } + + const effectiveTarget = resolvePackageTarget(name, target, options?.packageMode); + + if (effectiveTarget === "patch" && updateType !== "patch") { + continue; + } + + if (effectiveTarget === "minor" && updateType === "major") { + continue; + } + + const prefix = extractPrefix(range); + + outdated.push({ + catalogName: `${directory}:${depType}`, + currentRange: range, + newRange: `${prefix}${targetVersion}`, + packageName: name, + targetVersion, + updateType, + }); + } + } + } + + return { ignored: [...ignoredSet], outdated }; +}; + +const toFilterArray = (value: string | string[] | undefined): string[] => { + if (!value) { + return []; + } + + return Array.isArray(value) ? value : [value]; +}; + +const applyCatalogUpdates = ( + workspaceRoot: string, + updates: OutdatedEntry[], + packageManager?: string, + backup = true, + options: ApplyCatalogUpdatesOptions = {}, +): string | undefined => { + const { useEditorconfig } = options; + let backupPath: string | undefined; + + if (backup) { + backupPath = createBackup(workspaceRoot, packageManager, updates); + } + + // Split updates into catalog entries and direct package.json entries + const catalogUpdates: OutdatedEntry[] = []; + const pkgJsonUpdates: OutdatedEntry[] = []; + + for (const update of updates) { + if (parseCompositeCatalogName(update.catalogName)) { + pkgJsonUpdates.push(update); + } else { + catalogUpdates.push(update); + } + } + + // Apply catalog updates via the appropriate provider + if (catalogUpdates.length > 0) { + if (packageManager === "npm" || packageManager === "yarn") { + applyPackageJsonUpdates(workspaceRoot, catalogUpdates, useEditorconfig); + } else if (packageManager === "bun") { + applyBunCatalogUpdates(workspaceRoot, catalogUpdates, useEditorconfig); + } else { + applyPnpmCatalogUpdates(workspaceRoot, catalogUpdates); + } + } + + // Apply direct package.json version updates + if (pkgJsonUpdates.length > 0) { + applyPackageJsonUpdates(workspaceRoot, pkgJsonUpdates, useEditorconfig); + } + + return backupPath; +}; + +export { applyCatalogUpdates, applyPackageJsonUpdates, collectInternalOutdated, hasCatalogs, hasPackageJsonDeps, readCatalogs, readPackageJsonDeps, toFilterArray }; diff --git a/packages/tooling/vis/src/util/nested-field.ts b/packages/tooling/vis/src/util/nested-field.ts new file mode 100644 index 00000000..a3ece1a7 --- /dev/null +++ b/packages/tooling/vis/src/util/nested-field.ts @@ -0,0 +1,24 @@ +/** + * Sets `object[...path][key] = value` by dot path. Each missing or non-object + * step (including the last `path` segment) is replaced with an empty object. + */ +export const setNestedField = (object: Record, dotPath: string, key: string, value: string): void => { + const parts = dotPath.split("."); + let current: Record = object; + + for (const part of parts.slice(0, -1)) { + if (!current[part] || typeof current[part] !== "object") { + current[part] = {}; + } + + current = current[part] as Record; + } + + const lastPart = parts.at(-1)!; + + if (!current[lastPart] || typeof current[lastPart] !== "object") { + current[lastPart] = {}; + } + + (current[lastPart] as Record)[key] = value; +}; diff --git a/packages/tooling/vis/src/util/process-alive.ts b/packages/tooling/vis/src/util/process-alive.ts new file mode 100644 index 00000000..a19d5519 --- /dev/null +++ b/packages/tooling/vis/src/util/process-alive.ts @@ -0,0 +1,13 @@ +/** + * True when `pid` names a live process this user can signal. Signal 0 sends + * nothing; it only checks that the process exists and is reachable. + */ +export const isPidAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + + return true; + } catch { + return false; + } +}; diff --git a/packages/tooling/vis/src/util/shell-quote.ts b/packages/tooling/vis/src/util/shell-quote.ts new file mode 100644 index 00000000..1a1371c7 --- /dev/null +++ b/packages/tooling/vis/src/util/shell-quote.ts @@ -0,0 +1,41 @@ +/** + * Shell quoting shared by the release and run commands. `sq` and `cmdq` are + * the two primitives; `shellQuote` picks one by platform (or by an explicit + * `isWindows` flag, which the release tokens use). + */ + +/** + * Wrap a string in POSIX single quotes, escaping embedded single quotes. + * Safe to interpolate into `sh -c <cmd>` lines without injection risk. + * Port of bumpy's `sq()` helper (RFC §19.4). + * @example + * sq("hello") // returns "'hello'" + * sq("it's fine") // returns "'it'\\''s fine'" + * sq("$(rm -rf /)") // returns "'$(rm -rf /)'" + */ +export const sq = (value: string): string => `'${value.replaceAll("'", String.raw`'\''`)}'`; + +/** + * Windows `cmd.exe` analogue of {@link sq}. `cmd.exe` does not treat single + * quotes as quoting characters, so `sq()` gives no protection there — a value + * like a registry URL containing shell metacharacters would break out of its + * argument slot. Wrapping in double quotes makes those metacharacters (pipe, + * redirection, caret, parentheses, and whitespace) literal; embedded double + * quotes are doubled per the cmd convention. + * + * Caveat: `%VAR%` / delayed-`!VAR!` expansion still happens inside double + * quotes. That residual is acceptable because custom commands are gated behind + * `allowCustomCommands`, and the substituted token values are the maintainer's + * own (package name — charset-validated — version, tag, registry). + * @example + * cmdq("hello") // returns '"hello"' + * cmdq('say "hi"') // returns '"say ""hi"""' + */ +export const cmdq = (value: string): string => `"${value.replaceAll(`"`, `""`)}"`; + +/** + * Quote a token value for whichever shell will run the command — `sh -c` on + * POSIX, `cmd /c` on Windows. The orchestrator and shell action both pick the + * shell from `process.platform`, so the quoting must match that decision. + */ +export const shellQuote = (value: string, isWindows: boolean = process.platform === "win32"): string => (isWindows ? cmdq(value) : sq(value));