From a4c96f4ca89164f14f6768ce69973fba0cc18d6f Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 01:52:11 +0200 Subject: [PATCH 01/21] refactor(vis): split publish context into phases Move the publish wave into publish-wave, publish-loop, publish-tags and publish-post modules. publishContext keeps its signature and orchestrates the phases in the same order, with the same awaits and lock handling. Co-Authored-By: Claude Haiku 5.5 --- .../release/core/orchestrator/publish-loop.ts | 219 ++++ .../release/core/orchestrator/publish-post.ts | 394 +++++++ .../release/core/orchestrator/publish-tags.ts | 177 ++++ .../release/core/orchestrator/publish-wave.ts | 176 ++++ .../src/release/core/orchestrator/publish.ts | 962 ++---------------- 5 files changed, 1035 insertions(+), 893 deletions(-) create mode 100644 packages/tooling/vis/src/release/core/orchestrator/publish-loop.ts create mode 100644 packages/tooling/vis/src/release/core/orchestrator/publish-post.ts create mode 100644 packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts create mode 100644 packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts 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..c74fec20 --- /dev/null +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts @@ -0,0 +1,177 @@ +import { VisReleaseError } from "../../errors"; +import type { resolveSyncTagGroups } from "../group-tags"; +import type { PublishWave } from "./publish-wave"; +import { commitPublishLock, persistWaveState } from "./publish-wave"; + +/** + * 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, 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. + 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..241ee145 --- /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; +} + +export 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}. */ +export 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..169550e7 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 } 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. @@ -217,9 +175,62 @@ const verifyReleasePushes = async ( return 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. Records the + * refusal on `result` and returns `true` when the publish must stop. + */ +const refuseWhileReleasePrOpen = async ( + context: OrchestratorContext, + runner: ReturnType, + result: PublishContextResult, +): Promise => { + 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); + 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, + }); + + if (list.exitCode === 0 && list.stdout.trim() && list.stdout.trim() !== "[]") { + const parsed = JSON.parse(list.stdout) as { number: number }[]; + + 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). + } + } + + 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 }; @@ -229,44 +240,8 @@ export const publishContext = async (context: OrchestratorContext, options: Publ return result; } - // 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"); - 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, - }); - - if (list.exitCode === 0 && list.stdout.trim() && list.stdout.trim() !== "[]") { - const parsed = JSON.parse(list.stdout) as { number: number }[]; - - 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 result; - } - } - } catch { - // gh not available — skip the check (fail-open). - } - } + if (!options.dryRun && context.channel?.mode === "version-pr" && (await refuseWhileReleasePrOpen(context, runner, result))) { + return result; } if (!options.dryRun && context.config.prePublishCommand) { @@ -310,817 +285,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); - - 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 = 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}`); + // 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 }); - // 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" }); + await runPublishLoop(wave); - // 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"; + const syncTags = await createPublishTags(wave); - 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 { From 9b9aa57b19a007d50457a024c84ef94a09fbd9e0 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:01:24 +0200 Subject: [PATCH 02/21] refactor(vis): add run selection phases Move workspace load, project selection, target filtering and task build out of execute into handler-parts modules. Shared state lives on a RunContext that each phase reads and updates in place. Co-Authored-By: Claude Haiku 5.5 --- .../commands/run/handler-parts/run-context.ts | 103 +++ .../commands/run/handler-parts/run-select.ts | 587 ++++++++++++++++++ 2 files changed, 690 insertions(+) create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-context.ts create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-select.ts 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/run-select.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts new file mode 100644 index 00000000..559a17d8 --- /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 { parseHashMode, renderLastRunSummary } from "./parse"; +import type { RunContext, RunToolbox } from "./run-context"; +import { createRunContext } from "./run-context"; +import { FORWARDED_ARGS_KEY } from "./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; +}; From 38d655a5d40175f59de8398379af724d5bc3f7a3 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:01:24 +0200 Subject: [PATCH 03/21] refactor(vis): add run graph and service phases Move task graph build, preflight checks, service attach and injection, and dry-run plan printing into handler-parts modules. Phase order and the points where execution can stop early are unchanged. Co-Authored-By: Claude Haiku 5.5 --- .../commands/run/handler-parts/run-graph.ts | 313 +++++++++++++++ .../run/handler-parts/run-services.ts | 355 ++++++++++++++++++ 2 files changed, 668 insertions(+) create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-graph.ts create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-services.ts 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-services.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts new file mode 100644 index 00000000..8add544f --- /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 { SERVICE_PROBE_TIMEOUT_MS } from "./persistent-tasks"; +import type { RunContext } from "./run-context"; + +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 }); + } + } +}; From 147c20da7b984f85497a960e31a213a752cbc4ef Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:01:26 +0200 Subject: [PATCH 04/21] refactor(vis): add run runtime and mode phases Move hook and runner-option setup, cleanup signal wiring, the interactive loop, and the static and watch modes into handler-parts modules. The interactive loop stays one function because its retry state is written from inside a promise callback. Co-Authored-By: Claude Haiku 5.5 --- .../commands/run/handler-parts/run-modes.ts | 234 +++++++++ .../commands/run/handler-parts/run-runtime.ts | 453 ++++++++++++++++++ .../commands/run/handler-parts/run-static.ts | 404 ++++++++++++++++ 3 files changed, 1091 insertions(+) create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts create mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-static.ts 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..814fcaba --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts @@ -0,0 +1,234 @@ +import type { ProcessEvent, TaskResults } from "@visulima/task-runner"; +import { CompositeLifeCycle, defaultTaskRunner, runConcurrently, TerminalBuffer } from "@visulima/task-runner"; + +import { createDynamicOutputRenderer } from "../../../tui/dynamic-life-cycle"; +import type { StdinEntry } from "../../../tui/types"; +import { resolveRetrySpawn, resolveTaskSpawn } from "../task-env"; +import type { MutexPool } from "./executor"; +import { createConcurrentExecutor } from "./executor"; +import { MAX_OUTPUT_BYTES } from "./output"; +import { runPersistentTasks } from "./persistent-tasks"; +import type { RunContext } from "./run-context"; +import type { RunRuntime } from "./run-runtime"; +import { resolveTaskCommand } from "./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 { 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, ctx.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.`); + } + } +}; diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts new file mode 100644 index 00000000..40b24173 --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts @@ -0,0 +1,453 @@ +import type { ChildEnvOptions, Task, TaskResults, TaskRunnerOptions } from "@visulima/task-runner"; +import { generateRunSummary, pickChildParentEnv, resolveTurboEnvCompat, writeChromeTrace } from "@visulima/task-runner"; +import isInCi from "is-in-ci"; + +import { applyBranchScope, resolveSharedCacheDirectory } from "../../../cache/cache-directory"; +import { FailureLogLifeCycle } from "../../../report/failure-log"; +import { deleteEntry } from "../../../services/registry"; +import { checkStrictEnv, formatStrictEnvError } from "../../../task/strict-env"; +import { resolveOutputStyle } from "../../../tui/static-life-cycle"; +import { createVisHooks, HookableLifeCycle, registerPlugins } from "../../../util/hooks"; +import { getVisWorkspaceDataDir } from "../../../util/vis-paths"; +import { createTaskSpawnContext, effectiveChildEnvPolicy, getTaskOptions, resolveTaskSpawn } from "../task-env"; +import { createRetryBudget } from "./executor"; +import { parseCacheBackend, parseCacheMode, parseEnvConcurrency } from "./parse"; +import { cleanupEphemeralServices } from "./persistent-tasks"; +import type { RunContext } from "./run-context"; +import type { ServiceRegistryResult } from "./run-services"; +import { resolveTaskCommand } from "./shell"; + +/** + * Phase 8: everything the run modes need — hooks, runner options, remote cache, + * lifecycle options, spawn context. Ends with the `run:before` hook. + */ +export const prepareRunRuntime = async (ctx: RunContext, serviceResult: ServiceRegistryResult) => { + 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-static.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-static.ts new file mode 100644 index 00000000..7d509c6a --- /dev/null +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-static.ts @@ -0,0 +1,404 @@ +import type { LogMode, TaskResults } from "@visulima/task-runner"; +import { CompositeLifeCycle, createLogReporter, defaultTaskRunner, generateRunSummary, 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 { StaticOutputLifeCycle } from "../../../tui/static-life-cycle"; +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 { resolveTaskSpawn } from "../task-env"; +import type { MutexPool } from "./executor"; +import { createConcurrentExecutor } from "./executor"; +import { runPersistentTasks } from "./persistent-tasks"; +import type { RunContext } from "./run-context"; +import type { RunRuntime } from "./run-runtime"; + +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, + }, + }); + }); +}; From a75bfc6192ee03751db1e01ba52f7b901e67ba32 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:01:26 +0200 Subject: [PATCH 05/21] refactor(vis): make run execute an orchestrator execute keeps its signature and default export. It now calls the phase functions in the original order and runs the same try/finally teardown. Co-Authored-By: Claude Haiku 5.5 --- .../tooling/vis/src/commands/run/handler.ts | 2157 +---------------- 1 file changed, 30 insertions(+), 2127 deletions(-) diff --git a/packages/tooling/vis/src/commands/run/handler.ts b/packages/tooling/vis/src/commands/run/handler.ts index 420147ee..fca7e05a 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -1,2159 +1,62 @@ 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 } 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 { runStaticMode } from "./handler-parts/run-static"; 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); + finalizeServiceGraph(ctx, serviceResult); + shapeTaskGraph(ctx); - try { - await hooks.callHook("service:attach", entry, taskIds); - } catch (error) { - onHookError("service:attach", error); - } - } + if (printDryRunPlan(ctx)) { + return; } - await hooks.callHook("run:before", { tasks: initialTasks, workspaceRoot }); - - /** - * 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) { - 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); - } + const runtime = await prepareRunRuntime(ctx, serviceResult); + const cleanup = armRunCleanup(ctx); 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(); - - 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); } }; From 2bc295b4e81ad6039c0aca287cbd97e0f01deb57 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:12:12 +0200 Subject: [PATCH 06/21] refactor(vis): read currentOs once in run modes Destructure currentOs from the run context at the top of the interactive loop, so the retry branch passes the same expression as before. Co-Authored-By: Claude Haiku 5.5 --- .../tooling/vis/src/commands/run/handler-parts/run-modes.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 814fcaba..b84571a4 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts @@ -17,7 +17,7 @@ import { resolveTaskCommand } from "./shell"; * through the store, so the whole loop stays in one function. */ export const runInteractiveMode = async (ctx: RunContext, runtime: RunRuntime): Promise => { - const { logger, options, projectGraph, workspaceRoot } = ctx; + 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; @@ -120,7 +120,7 @@ export const runInteractiveMode = async (ctx: RunContext, runtime: RunRuntime): // 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, ctx.currentOs) : undefined; + const command = task ? resolveTaskCommand(task, spawnContext.affectedFiles, currentOs) : undefined; if (task && command) { lifeCycle.startTasks?.([task]); From 8d7ad8afcae3dbb6d60f69ed6c6cd42e256a084d Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:13:59 +0200 Subject: [PATCH 07/21] test(vis): read moved retry branch in spawn tests The retry branch moved from handler.ts into handler-parts/run-modes.ts. The source-string assertions now read both files. Co-Authored-By: Claude Haiku 5.5 --- .../vis/__tests__/commands/run/retry-spawn.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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)); From a41cbbd1a1761cbe649da52d6735401a285c76b9 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:21:20 +0200 Subject: [PATCH 08/21] fix(vis): tear down services when run setup throws Cleanup was armed after prepareRunRuntime, so a failure in the remote cache attestation check or a run:before hook left services booted by this run running. Arm cleanup as soon as services are attached and keep the rest of the run inside the try/finally. Co-Authored-By: Claude Haiku 5.5 --- .../run/run-teardown-on-throw.test.ts | 93 +++++++++++++++++++ .../tooling/vis/src/commands/run/handler.ts | 17 ++-- 2 files changed, 104 insertions(+), 6 deletions(-) create mode 100644 packages/tooling/vis/__tests__/commands/run/run-teardown-on-throw.test.ts 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..b2515a03 --- /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/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/src/commands/run/handler.ts b/packages/tooling/vis/src/commands/run/handler.ts index fca7e05a..628e6b2b 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -44,16 +44,21 @@ const execute = async ({ argument, logger, options, visConfig, workspaceRoot: ws injectMissingServices(ctx, serviceResult); finalizeServiceGraph(ctx, serviceResult); - shapeTaskGraph(ctx); - if (printDryRunPlan(ctx)) { - return; - } - - const runtime = await prepareRunRuntime(ctx, serviceResult); + // Arm before anything that can throw: services may already be booted by + // now, so a failure in shaping, the runtime hooks or the modes must still + // tear them down. const cleanup = armRunCleanup(ctx); try { + shapeTaskGraph(ctx); + + if (printDryRunPlan(ctx)) { + return; + } + + const runtime = await prepareRunRuntime(ctx, serviceResult); + await (runtime.isTTY ? runInteractiveMode(ctx, runtime) : runStaticMode(ctx, runtime)); } finally { await teardownRun(ctx, cleanup); From a7df06385bac8ad3ce34fdf9cd69dcd784ae6c90 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:23:59 +0200 Subject: [PATCH 09/21] refactor(vis): drop run handler re-export shim The retry budget test now imports createRetryBudget from its owning module, so the handler no longer re-exports it. The handler doc comment uses a TARGET placeholder: the jsdoc escaping rule rewrites a bare angle-bracket placeholder into an entity, which rendered literally. Co-Authored-By: Claude Haiku 5.5 --- .../tooling/vis/__tests__/commands/run/retry-budget.test.ts | 2 +- packages/tooling/vis/src/commands/run/handler.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) 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..743d9ddf 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/executor"; describe(createRetryBudget, () => { it("grants up to the requested amount and decrements remaining", () => { diff --git a/packages/tooling/vis/src/commands/run/handler.ts b/packages/tooling/vis/src/commands/run/handler.ts index 628e6b2b..7cdb38c8 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -9,7 +9,7 @@ import { runStaticMode } from "./handler-parts/run-static"; import type { RunOptions } from "./index"; /** - * `vis run <target>`: select projects, build and preflight the task graph, + * `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. @@ -66,6 +66,3 @@ const execute = async ({ argument, logger, options, visConfig, workspaceRoot: ws }; export default execute as CommandExecute; - -export type { RetryBudget } from "./handler-parts/executor"; -export { createRetryBudget } from "./handler-parts/executor"; From bf2226f4d7d29e1dbaef56a0b60527e4b9553dd5 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:26:42 +0200 Subject: [PATCH 10/21] fix(vis): report publish preflight errors as failures Remote provider and repo detection in the release-PR check and the push-rule check could reject, so publishContext threw instead of returning a result. Record those errors as a _preflight failure and stop, so the caller gets the same result shape as other refusals. Co-Authored-By: Claude Haiku 5.5 --- .../core/orchestrator-preflight-guard.test.ts | 141 ++++++++++++++++++ .../src/release/core/orchestrator/publish.ts | 37 ++++- 2 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 packages/tooling/vis/__tests__/release/core/orchestrator-preflight-guard.test.ts 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/src/release/core/orchestrator/publish.ts b/packages/tooling/vis/src/release/core/orchestrator/publish.ts index 169550e7..574c685f 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/publish.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/publish.ts @@ -187,13 +187,26 @@ const refuseWhileReleasePrOpen = async ( result: PublishContextResult, ): Promise => { 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); + let repo: string | undefined; + + 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, + }); + + 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 (repo) { @@ -257,7 +270,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 }); From 35da97af547de10a0b231313eabff170b35e3f80 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:30:38 +0200 Subject: [PATCH 11/21] refactor(vis): unexport unused publish wave types PublishWaveModules and PublishWaveBase are only used inside publish-wave, so they no longer need to be exported. Co-Authored-By: Claude Haiku 5.5 --- .../tooling/vis/src/release/core/orchestrator/publish-wave.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts index 241ee145..4ea5e722 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-wave.ts @@ -68,7 +68,7 @@ export interface PublishWave { state: StateFile; } -export interface PublishWaveModules { +interface PublishWaveModules { registry: { readStagedRegistry: typeof readStagedRegistry; removePendingStages: typeof removePendingStages; @@ -85,7 +85,7 @@ export interface PublishWaveModules { } /** Phase-independent inputs handed to {@link openPublishWave}. */ -export interface PublishWaveBase { +interface PublishWaveBase { context: OrchestratorContext; options: PublishContextOptions; result: PublishContextResult; From c421e61337d48ac874cc59122abb31892b430845 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:35:13 +0200 Subject: [PATCH 12/21] refactor(vis): plan publish tags once for preflight and tagging The push-rule preflight and the tag phase each derived the release and floating tags from the plan on their own. planPublishTags now owns that decision; both callers use it, so the preflight checks the tags the run creates. Tag names, floating-tag conditions and creation order are unchanged. Co-Authored-By: Claude Haiku 5.5 --- .../release/core/orchestrator/publish-tags.ts | 153 +++++++++++------- .../src/release/core/orchestrator/publish.ts | 31 +--- 2 files changed, 97 insertions(+), 87 deletions(-) diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts b/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts index c74fec20..994caf5c 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/publish-tags.ts @@ -1,13 +1,85 @@ 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> => { +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 @@ -17,26 +89,10 @@ export const createPublishTags = async (wave: PublishWave): Promise 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); + 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 }, @@ -64,56 +120,29 @@ export const createPublishTags = async (wave: PublishWave): Promise-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}`, - }); - } + 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) { + for (const group of plan.groups) { try { await createTag({ cwd: context.cwd, runner }, group.tag, `Release ${group.name} ${group.version}`, { signing: context.config.signing, diff --git a/packages/tooling/vis/src/release/core/orchestrator/publish.ts b/packages/tooling/vis/src/release/core/orchestrator/publish.ts index 574c685f..a8d3f690 100644 --- a/packages/tooling/vis/src/release/core/orchestrator/publish.ts +++ b/packages/tooling/vis/src/release/core/orchestrator/publish.ts @@ -5,7 +5,7 @@ import { runHook } from "./apply"; import type { OrchestratorContext } from "./context"; import { runPublishLoop } from "./publish-loop"; import { commitStagedRegistry, runPostPublish, writeChangelogWave } from "./publish-post"; -import { createPublishTags } from "./publish-tags"; +import { createPublishTags, planPublishTags } from "./publish-tags"; import { openPublishWave } from "./publish-wave"; // ── Publish phase ────────────────────────────────────────────────── @@ -54,7 +54,6 @@ export interface PublishContextResult { * 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) @@ -62,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); + const plan = await planPublishTags(context, planned, syncTags); - for (const { name, version } of planned) { - if (syncTags.grouped.has(name)) { - continue; - } - - 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])), + }; }; /** From 6f76c948b0302abc13a798863f65a78eb1c2e614 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:37:59 +0200 Subject: [PATCH 13/21] refactor(vis): dedupe liveness, quoting and visOptions helpers isPidAlive now lives in util/process-alive and the service registry and release state share it. Persistent tasks keep their group-then-pid check on top of it. The shell quote primitives move to util/shell-quote; the run shell keeps its Windows bare-word rule over the shared quoting, and the two shellQuote signatures are now named for what they do. getVisOptions moves to task/target-options so both run modules import one copy. Co-Authored-By: Claude Haiku 5.5 --- .../commands/run/apply-service-registry.ts | 8 +--- .../run/handler-parts/persistent-tasks.ts | 15 +++---- .../src/commands/run/handler-parts/shell.ts | 39 +++++++----------- .../vis/src/commands/run/service-preflight.ts | 11 +---- .../tooling/vis/src/release/core/security.ts | 39 ++---------------- .../tooling/vis/src/release/core/state.ts | 13 +----- packages/tooling/vis/src/services/registry.ts | 11 +---- .../tooling/vis/src/task/target-options.ts | 8 ++++ .../tooling/vis/src/util/process-alive.ts | 13 ++++++ packages/tooling/vis/src/util/shell-quote.ts | 41 +++++++++++++++++++ 10 files changed, 90 insertions(+), 108 deletions(-) create mode 100644 packages/tooling/vis/src/util/process-alive.ts create mode 100644 packages/tooling/vis/src/util/shell-quote.ts 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/persistent-tasks.ts b/packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts index 7f310ef6..34f3f0be 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/persistent-tasks.ts @@ -7,6 +7,7 @@ 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"; @@ -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 -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/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/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/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)); From 30842606fe8aad312a754c7cb33f254d0cabad8f Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:38:05 +0200 Subject: [PATCH 14/21] refactor(vis): break config type cycle for scoped tasks ScopedTasksMatch moves into types-parts/project next to the project types it refers to, so the part no longer imports from types.ts. types.ts still re-exports it. Co-Authored-By: Claude Haiku 5.5 --- .../vis/src/config/types-parts/project.ts | 19 ++++++++++++++++- packages/tooling/vis/src/config/types.ts | 21 ++----------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/tooling/vis/src/config/types-parts/project.ts b/packages/tooling/vis/src/config/types-parts/project.ts index 056336b9..218d5e08 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"; /** * One user-declared customTypes entry. See `policy.customTypes.extraTypes` * for the full contract — this is just the row shape. */ + +/** + * 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[]; +} + 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"; From 7e9b6e6610e7eba7e213c0de34bc82730aacf868 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:40:21 +0200 Subject: [PATCH 15/21] refactor(vis): type orchestrator discovered projects Name the project types the discovered-ecosystem map holds, and use a top-level type import for the pre-mode file type. Co-Authored-By: Claude Haiku 5.5 --- .../src/release/core/orchestrator/context.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 From df854e76ae21925d3e75a31dab2d48e8640489e3 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:40:22 +0200 Subject: [PATCH 16/21] refactor(vis): share one nested field setter setNestedField was copied into util/catalog and deps/workspace-protocol. Both copies behave the same, so they now import one helper. Co-Authored-By: Claude Haiku 5.5 --- .../vis/src/deps/workspace-protocol.ts | 27 +------------------ packages/tooling/vis/src/util/catalog.ts | 22 +-------------- packages/tooling/vis/src/util/nested-field.ts | 24 +++++++++++++++++ 3 files changed, 26 insertions(+), 47 deletions(-) create mode 100644 packages/tooling/vis/src/util/nested-field.ts 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/util/catalog.ts b/packages/tooling/vis/src/util/catalog.ts index 96079647..5d445c61 100644 --- a/packages/tooling/vis/src/util/catalog.ts +++ b/packages/tooling/vis/src/util/catalog.ts @@ -11,6 +11,7 @@ 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"; +import { setNestedField } from "./nested-field"; const getDepTypesToInclude = (options?: ReadCatalogOptions): string[] => { if (options?.dev) { @@ -72,27 +73,6 @@ const getNestedField = (object: Record, path: string): 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(); 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; +}; From b1c538e7b2b16fa2c781a14d9c750276c36d5572 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:44:46 +0200 Subject: [PATCH 17/21] refactor(vis): merge run modes, prefix run parts The three run mode functions now sit in run-modes.ts; run-static.ts is gone. Handler-parts files all carry the run- prefix (seven of twelve already did), so executor, output, parse, persistent-tasks and shell are renamed to match. Imports are updated to the new paths. Co-Authored-By: Claude Haiku 5.5 --- .../commands/run/retry-budget.test.ts | 2 +- .../run/run-teardown-on-throw.test.ts | 2 +- .../{executor.ts => run-executor.ts} | 2 +- .../commands/run/handler-parts/run-modes.ts | 409 +++++++++++++++++- .../{output.ts => run-output.ts} | 0 .../handler-parts/{parse.ts => run-parse.ts} | 0 ...stent-tasks.ts => run-persistent-tasks.ts} | 4 +- .../commands/run/handler-parts/run-runtime.ts | 8 +- .../commands/run/handler-parts/run-select.ts | 4 +- .../run/handler-parts/run-services.ts | 2 +- .../handler-parts/{shell.ts => run-shell.ts} | 0 .../commands/run/handler-parts/run-static.ts | 404 ----------------- .../tooling/vis/src/commands/run/handler.ts | 3 +- 13 files changed, 415 insertions(+), 425 deletions(-) rename packages/tooling/vis/src/commands/run/handler-parts/{executor.ts => run-executor.ts} (99%) rename packages/tooling/vis/src/commands/run/handler-parts/{output.ts => run-output.ts} (100%) rename packages/tooling/vis/src/commands/run/handler-parts/{parse.ts => run-parse.ts} (100%) rename packages/tooling/vis/src/commands/run/handler-parts/{persistent-tasks.ts => run-persistent-tasks.ts} (99%) rename packages/tooling/vis/src/commands/run/handler-parts/{shell.ts => run-shell.ts} (100%) delete mode 100644 packages/tooling/vis/src/commands/run/handler-parts/run-static.ts 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 743d9ddf..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-parts/executor"; +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/run-teardown-on-throw.test.ts b/packages/tooling/vis/__tests__/commands/run/run-teardown-on-throw.test.ts index b2515a03..5f6ab621 100644 --- 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 @@ -47,7 +47,7 @@ vi.mock(import("../../../src/commands/run/handler-parts/run-runtime"), async (im return { ...actual, prepareRunRuntime: state.prepareRunRuntime }; }); -vi.mock(import("../../../src/commands/run/handler-parts/persistent-tasks"), async (importOriginal) => { +vi.mock(import("../../../src/commands/run/handler-parts/run-persistent-tasks"), async (importOriginal) => { const actual = await importOriginal(); return { ...actual, cleanupEphemeralServices: state.cleanupEphemeralServices }; 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-modes.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts index b84571a4..14f96204 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-modes.ts @@ -1,16 +1,25 @@ -import type { ProcessEvent, TaskResults } from "@visulima/task-runner"; -import { CompositeLifeCycle, defaultTaskRunner, runConcurrently, TerminalBuffer } from "@visulima/task-runner"; +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 { MutexPool } from "./executor"; -import { createConcurrentExecutor } from "./executor"; -import { MAX_OUTPUT_BYTES } from "./output"; -import { runPersistentTasks } from "./persistent-tasks"; 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 "./shell"; +import { resolveTaskCommand } from "./run-shell"; /** * Interactive (TTY) run: the dynamic renderer drives reruns, retries and quit @@ -232,3 +241,389 @@ export const runInteractiveMode = async (ctx: RunContext, runtime: RunRuntime): } } }; + +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 99% 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 34f3f0be..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 @@ -10,8 +10,8 @@ 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. diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts index 40b24173..35828e30 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-runtime.ts @@ -10,12 +10,12 @@ import { resolveOutputStyle } from "../../../tui/static-life-cycle"; import { createVisHooks, HookableLifeCycle, registerPlugins } from "../../../util/hooks"; import { getVisWorkspaceDataDir } from "../../../util/vis-paths"; import { createTaskSpawnContext, effectiveChildEnvPolicy, getTaskOptions, resolveTaskSpawn } from "../task-env"; -import { createRetryBudget } from "./executor"; -import { parseCacheBackend, parseCacheMode, parseEnvConcurrency } from "./parse"; -import { cleanupEphemeralServices } from "./persistent-tasks"; import type { RunContext } from "./run-context"; +import { createRetryBudget } from "./run-executor"; +import { parseCacheBackend, parseCacheMode, parseEnvConcurrency } from "./run-parse"; +import { cleanupEphemeralServices } from "./run-persistent-tasks"; import type { ServiceRegistryResult } from "./run-services"; -import { resolveTaskCommand } from "./shell"; +import { resolveTaskCommand } from "./run-shell"; /** * Phase 8: everything the run modes need — hooks, runner options, remote cache, 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 index 559a17d8..cf73cd7e 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-select.ts @@ -17,10 +17,10 @@ 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 { parseHashMode, renderLastRunSummary } from "./parse"; import type { RunContext, RunToolbox } from "./run-context"; import { createRunContext } from "./run-context"; -import { FORWARDED_ARGS_KEY } from "./shell"; +import { parseHashMode, renderLastRunSummary } from "./run-parse"; +import { FORWARDED_ARGS_KEY } from "./run-shell"; /** * Phase 0: resolve the runtime, sweep legacy state, handle `--last-details`, 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 index 8add544f..f1a549b4 100644 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts +++ b/packages/tooling/vis/src/commands/run/handler-parts/run-services.ts @@ -10,8 +10,8 @@ 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 { SERVICE_PROBE_TIMEOUT_MS } from "./persistent-tasks"; import type { RunContext } from "./run-context"; +import { SERVICE_PROBE_TIMEOUT_MS } from "./run-persistent-tasks"; export type ServiceRegistryResult = Awaited>; 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 100% 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 diff --git a/packages/tooling/vis/src/commands/run/handler-parts/run-static.ts b/packages/tooling/vis/src/commands/run/handler-parts/run-static.ts deleted file mode 100644 index 7d509c6a..00000000 --- a/packages/tooling/vis/src/commands/run/handler-parts/run-static.ts +++ /dev/null @@ -1,404 +0,0 @@ -import type { LogMode, TaskResults } from "@visulima/task-runner"; -import { CompositeLifeCycle, createLogReporter, defaultTaskRunner, generateRunSummary, 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 { StaticOutputLifeCycle } from "../../../tui/static-life-cycle"; -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 { resolveTaskSpawn } from "../task-env"; -import type { MutexPool } from "./executor"; -import { createConcurrentExecutor } from "./executor"; -import { runPersistentTasks } from "./persistent-tasks"; -import type { RunContext } from "./run-context"; -import type { RunRuntime } from "./run-runtime"; - -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.ts b/packages/tooling/vis/src/commands/run/handler.ts index 7cdb38c8..93af8f59 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -1,11 +1,10 @@ import type { CommandExecute, Toolbox } from "@visulima/cerebro"; import { buildRunGraph, printDryRunPlan, runPreflightChecks, shapeTaskGraph } from "./handler-parts/run-graph"; -import { runInteractiveMode } from "./handler-parts/run-modes"; +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 { runStaticMode } from "./handler-parts/run-static"; import type { RunOptions } from "./index"; /** From b1115db2e2a2dd4a283f353dbc0a3c00943bd4a1 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:46:04 +0200 Subject: [PATCH 18/21] refactor(vis): move catalog code to engine util/catalog.ts is now only the public re-export list. The implementation moves to util/catalog/engine.ts, next to the existing catalog helper modules, with import paths adjusted. No importer path changes. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/src/util/catalog.ts | 516 +----------------- .../tooling/vis/src/util/catalog/engine.ts | 512 +++++++++++++++++ 2 files changed, 515 insertions(+), 513 deletions(-) create mode 100644 packages/tooling/vis/src/util/catalog/engine.ts diff --git a/packages/tooling/vis/src/util/catalog.ts b/packages/tooling/vis/src/util/catalog.ts index 5d445c61..1db146e2 100644 --- a/packages/tooling/vis/src/util/catalog.ts +++ b/packages/tooling/vis/src/util/catalog.ts @@ -1,520 +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"; -import { setNestedField } from "./nested-field"; - -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 }; - 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 }; From c2da35168c1870ce1724dd75920b1a262764edc0 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:48:51 +0200 Subject: [PATCH 19/21] refactor(vis): split cache maintenance and why The cache maintenance commands (clean, prune and their eviction helpers) move to cache/maintenance.ts, and the why report to cache/why.ts. Shared entry and output helpers move to cache/shared.ts. handler.ts keeps list, hash, size and verify, and re-exports the moved names so existing imports still resolve. index.ts loads each moved entry from its new module. Co-Authored-By: Claude Haiku 5.5 --- .../tooling/vis/src/commands/cache/handler.ts | 931 +----------------- .../tooling/vis/src/commands/cache/index.ts | 6 +- .../vis/src/commands/cache/maintenance.ts | 382 +++++++ .../tooling/vis/src/commands/cache/shared.ts | 300 ++++++ .../tooling/vis/src/commands/cache/why.ts | 255 +++++ 5 files changed, 957 insertions(+), 917 deletions(-) create mode 100644 packages/tooling/vis/src/commands/cache/maintenance.ts create mode 100644 packages/tooling/vis/src/commands/cache/shared.ts create mode 100644 packages/tooling/vis/src/commands/cache/why.ts 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, + ); +}; From db64e87f3cf47b93b189598da0d20685e482af15 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:50:35 +0200 Subject: [PATCH 20/21] refactor(vis): keep customTypes doc with its type Moving ScopedTasksMatch left the customTypes row doc detached from its interface. Restore that doc and put the moved interface above it. The regenerated schema changes only the ScopedTasksMatch description, which now reads as plain text instead of an unresolved {@link}. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/schemas/vis-config.schema.json | 2 +- packages/tooling/vis/src/config/types-parts/project.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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/config/types-parts/project.ts b/packages/tooling/vis/src/config/types-parts/project.ts index 218d5e08..e2430150 100644 --- a/packages/tooling/vis/src/config/types-parts/project.ts +++ b/packages/tooling/vis/src/config/types-parts/project.ts @@ -3,11 +3,6 @@ import type { ProjectConfiguration, TargetConfiguration } from "@visulima/task-r import type { VisTargetConfiguration } from "../../task/target-options"; import type { OwnersEntry } from "./codeowners"; -/** - * One user-declared customTypes entry. See `policy.customTypes.extraTypes` - * for the full contract — this is just the row shape. - */ - /** * A predicate used by the `scopedTasks` config block. * All listed constraints must match for the block to apply. @@ -25,6 +20,11 @@ export interface ScopedTasksMatch { 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 From 5148a3625ea89573fa44f41a5fdc77f55ac31613 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 17 Oct 2026 02:55:58 +0200 Subject: [PATCH 21/21] fix(vis): arm run cleanup before graph finalize Cleanup was armed after finalizeServiceGraph, which ran outside the try. Arm it straight after the services boot and move the finalize step inside the try/finally, so a throw there also tears services down. Co-Authored-By: Claude Haiku 5.5 --- packages/tooling/vis/src/commands/run/handler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/tooling/vis/src/commands/run/handler.ts b/packages/tooling/vis/src/commands/run/handler.ts index 93af8f59..c196c52c 100644 --- a/packages/tooling/vis/src/commands/run/handler.ts +++ b/packages/tooling/vis/src/commands/run/handler.ts @@ -42,14 +42,14 @@ const execute = async ({ argument, logger, options, visConfig, workspaceRoot: ws const serviceResult = await attachRegisteredServices(ctx); injectMissingServices(ctx, serviceResult); - finalizeServiceGraph(ctx, serviceResult); - // Arm before anything that can throw: services may already be booted by - // now, so a failure in shaping, the runtime hooks or the modes must still - // tear them down. + // 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); try { + finalizeServiceGraph(ctx, serviceResult); shapeTaskGraph(ctx); if (printDryRunPlan(ctx)) {