diff --git a/.changeset/calm-generations-retire.md b/.changeset/calm-generations-retire.md new file mode 100644 index 000000000..4bbdd41e5 --- /dev/null +++ b/.changeset/calm-generations-retire.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Bound `eve dev` runtime snapshot storage with a World-independent retention policy. Superseded generations remain available for at least 30 minutes, and the five most recently superseded generations are retained as an additional rebuild-rate safety net. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a744fd64f..be3670fb3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -158,7 +158,7 @@ For bearer tokens or custom schemes, pass explicit headers with `-H`. Local dev records the last ready URL per resolved app root in `.eve/dev-server-state.v1.json`. A second interactive `eve dev` reconnects only when that URL is loopback and healthy; each terminal UI creates a fresh client session while sharing the server process. A stale or malformed record is replaced when eve starts a new server. Passing `--host`, `--port`, or a `PORT` environment value skips reconnection and reports a healthy recorded server instead. -Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight sessions hold a consistent code revision while new prompts pick up rebuilds. `eve dev` retains activated runtime snapshots so durable local Workflow turns can resume on their selected revision, and it prunes old local sandbox templates in the background. For manual cleanup, stop `eve dev` before deleting `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. Deleting runtime snapshots while retaining `.workflow-data` can prevent an unfinished turn from resuming; remove both only when you intend to discard local Workflow state. +Local dev keeps immutable runtime source snapshots under `.eve/dev-runtime/snapshots/` so in-flight turns hold a consistent code revision while new turns pick up rebuilds. After a generation is superseded, `eve dev` retains it for at least 30 minutes and also retains the five most recently superseded generations, regardless of the configured Workflow World. The active generation is never pruned. Old runtime snapshots and local sandbox templates are pruned in the background. For manual cleanup, stop `eve dev` before deleting `.eve/dev-runtime/snapshots/` or `.eve/sandbox-cache/local/templates/`. A turn that remains unfinished beyond the automatic retention window can no longer resume after its generation is pruned. ## `eve link` diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts-retention.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts-retention.ts new file mode 100644 index 000000000..c1e68a130 --- /dev/null +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts-retention.ts @@ -0,0 +1,152 @@ +import { existsSync, type Dirent } from "node:fs"; +import { readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export const DEVELOPMENT_RUNTIME_ARTIFACTS_ACTIVATED_MARKER = "activated"; + +const DEVELOPMENT_RUNTIME_ARTIFACTS_RETIRED_METADATA = "retired.json"; +const DEVELOPMENT_RUNTIME_SNAPSHOT_GRACE_PERIOD_MS = 30 * 60 * 1_000; +const DEVELOPMENT_RUNTIME_SNAPSHOT_RETAIN_COUNT = 5; + +/** Records the instant at which an activated generation stopped being current. */ +export async function recordRetiredDevelopmentRuntimeArtifactsSnapshot( + snapshotRoot: string, + retiredAt = Date.now(), +): Promise { + await writeFile( + join(snapshotRoot, DEVELOPMENT_RUNTIME_ARTIFACTS_RETIRED_METADATA), + `${JSON.stringify({ retiredAt })}\n`, + ); +} + +/** Applies the bounded retention policy within one dev snapshot directory. */ +export async function pruneDevelopmentRuntimeArtifactsSnapshotDirectory(input: { + readonly activeSnapshotRoot: string | undefined; + readonly gracePeriodMs?: number; + readonly now?: number; + readonly protectAll: boolean; + readonly retainCount?: number; + readonly snapshotsDirectory: string; +}): Promise { + if (input.protectAll) { + return; + } + const now = input.now ?? Date.now(); + const gracePeriodMs = Math.max( + 0, + input.gracePeriodMs ?? DEVELOPMENT_RUNTIME_SNAPSHOT_GRACE_PERIOD_MS, + ); + const retainCount = Math.max( + 0, + Math.trunc(input.retainCount ?? DEVELOPMENT_RUNTIME_SNAPSHOT_RETAIN_COUNT), + ); + let entries: Dirent[]; + try { + entries = await readdir(input.snapshotsDirectory, { withFileTypes: true }); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return; + } + throw error; + } + + const activeSnapshotRoot = + input.activeSnapshotRoot === undefined ? undefined : resolve(input.activeSnapshotRoot); + const snapshots = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map(async (entry) => { + const path = join(input.snapshotsDirectory, entry.name); + const active = activeSnapshotRoot === resolve(path); + const activated = existsSync(join(path, DEVELOPMENT_RUNTIME_ARTIFACTS_ACTIVATED_MARKER)); + let retiredAt: number | undefined; + if (activated && !active) { + try { + retiredAt = await readRetiredAt(path); + if (retiredAt === undefined) { + await recordRetiredDevelopmentRuntimeArtifactsSnapshot(path, now); + retiredAt = now; + } + } catch (error) { + console.warn( + `[eve:dev] failed to read or initialize runtime generation retirement metadata for "${path}": ${String(error)}`, + ); + } + } + return { + activated, + active, + path, + retiredAt, + mtimeMs: (await stat(path)).mtimeMs, + }; + }), + ); + const retainedRetiredPaths = new Set( + snapshots + .flatMap((snapshot) => + snapshot.retiredAt === undefined + ? [] + : [{ path: snapshot.path, retiredAt: snapshot.retiredAt }], + ) + .sort((left, right) => right.retiredAt - left.retiredAt) + .slice(0, retainCount) + .map((snapshot) => snapshot.path), + ); + const retainedStagedPaths = new Set( + snapshots + .filter((snapshot) => !snapshot.activated) + .sort((left, right) => right.mtimeMs - left.mtimeMs) + .slice(0, retainCount) + .map((snapshot) => snapshot.path), + ); + + await Promise.all( + snapshots.map(async (snapshot) => { + if ( + snapshot.active || + (snapshot.activated && snapshot.retiredAt === undefined) || + retainedRetiredPaths.has(snapshot.path) || + retainedStagedPaths.has(snapshot.path) || + (snapshot.retiredAt !== undefined && now - snapshot.retiredAt <= gracePeriodMs) || + (!snapshot.activated && now - snapshot.mtimeMs <= gracePeriodMs) + ) { + return; + } + await rm(snapshot.path, { force: true, recursive: true }); + }), + ); +} + +async function readRetiredAt(snapshotRoot: string): Promise { + let source: string; + try { + source = await readFile( + join(snapshotRoot, DEVELOPMENT_RUNTIME_ARTIFACTS_RETIRED_METADATA), + "utf8", + ); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return undefined; + } + throw error; + } + let metadata: unknown; + try { + metadata = JSON.parse(source); + } catch { + return undefined; + } + if ( + typeof metadata === "object" && + metadata !== null && + !Array.isArray(metadata) && + "retiredAt" in metadata && + typeof metadata.retiredAt === "number" && + Number.isFinite(metadata.retiredAt) && + metadata.retiredAt >= 0 + ) { + return metadata.retiredAt; + } + return undefined; +} diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts index 2ffbd5696..bccd43730 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts.integration.test.ts @@ -335,8 +335,8 @@ describe("development runtime artifact snapshots", () => { await pruneDevelopmentRuntimeArtifactsSnapshots({ appRoot, + gracePeriodMs: 5_000, now, - recentWindowMs: 5_000, retainCount: 2, }); @@ -346,7 +346,7 @@ describe("development runtime artifact snapshots", () => { expect(existsSync(staleSnapshotRoot)).toBe(false); }); - it("retains every activated generation until lease-aware pruning exists", async () => { + it("records when the active generation is superseded", async () => { const appRoot = await createScratchDirectory("eve-dev-runtime-activated-retention-"); const snapshotsRoot = join(appRoot, ".eve", "dev-runtime", "snapshots"); const firstSnapshotRoot = join(snapshotsRoot, "first"); @@ -354,52 +354,97 @@ describe("development runtime artifact snapshots", () => { for (const snapshotRoot of [firstSnapshotRoot, nextSnapshotRoot]) { await mkdir(snapshotRoot, { recursive: true }); - await utimes(snapshotRoot, new Date(1_000), new Date(1_000)); - await activateDevelopmentRuntimeArtifactsSnapshot({ - appRoot, - snapshot: { - runtimeAppRoot: join(snapshotRoot, "source"), - snapshotRoot, - snapshotSourceRoot: join(snapshotRoot, "source"), - sourceRoot: appRoot, - }, - }); } - - await pruneDevelopmentRuntimeArtifactsSnapshots({ + const createSnapshot = (snapshotRoot: string) => ({ + runtimeAppRoot: join(snapshotRoot, "source"), + snapshotRoot, + snapshotSourceRoot: join(snapshotRoot, "source"), + sourceRoot: appRoot, + }); + await activateDevelopmentRuntimeArtifactsSnapshot({ appRoot, - now: 1_000_000, - recentWindowMs: 0, - retainCount: 0, + snapshot: createSnapshot(firstSnapshotRoot), }); - - expect(existsSync(firstSnapshotRoot)).toBe(true); - expect(existsSync(nextSnapshotRoot)).toBe(true); + const beforeRetirement = Date.now(); + await activateDevelopmentRuntimeArtifactsSnapshot({ + appRoot, + snapshot: createSnapshot(nextSnapshotRoot), + }); + const afterRetirement = Date.now(); + + const retirement = JSON.parse( + await readFile(join(firstSnapshotRoot, "retired.json"), "utf8"), + ) as { retiredAt: number }; + expect(retirement.retiredAt).toBeGreaterThanOrEqual(beforeRetirement); + expect(retirement.retiredAt).toBeLessThanOrEqual(afterRetirement); + expect(existsSync(join(nextSnapshotRoot, "retired.json"))).toBe(false); }); - it("preserves snapshots referenced by active durable workflow data", async () => { - const appRoot = await createScratchDirectory("eve-dev-runtime-artifacts-prune-durable-"); + it("retains the active, five newest retired, and recently retired generations", async () => { + const appRoot = await createScratchDirectory("eve-dev-runtime-retired-pruning-"); const snapshotsRoot = join(appRoot, ".eve", "dev-runtime", "snapshots"); const activeSnapshotRoot = join(snapshotsRoot, "active"); - const posixParkedTurnSnapshotRoot = join(snapshotsRoot, "parked-turn-posix"); - const windowsParkedTurnSnapshotRoot = join(snapshotsRoot, "parked-turn-windows"); - const completedTurnSnapshotRoot = join(snapshotsRoot, "completed-turn"); - const staleSnapshotRoot = join(snapshotsRoot, "stale"); - const oldSnapshotTime = new Date(1_000); - const now = 1_000_000; + const withinGraceSnapshotRoot = join(snapshotsRoot, "within-grace"); + const expiredSnapshotRoots = Array.from({ length: 6 }, (_, index) => + join(snapshotsRoot, `expired-${String(index)}`), + ); + const now = 10_000_000; + const gracePeriodMs = 30 * 60 * 1_000; for (const snapshotRoot of [ activeSnapshotRoot, - posixParkedTurnSnapshotRoot, - windowsParkedTurnSnapshotRoot, - completedTurnSnapshotRoot, - staleSnapshotRoot, + withinGraceSnapshotRoot, + ...expiredSnapshotRoots, ]) { await mkdir(snapshotRoot, { recursive: true }); - await writeFile(join(snapshotRoot, "marker.txt"), snapshotRoot); - await utimes(snapshotRoot, oldSnapshotTime, oldSnapshotTime); + await writeFile(join(snapshotRoot, "activated"), ""); + } + await writeFile( + join(withinGraceSnapshotRoot, "retired.json"), + `${JSON.stringify({ retiredAt: now - gracePeriodMs + 1 })}\n`, + ); + for (const [index, snapshotRoot] of expiredSnapshotRoots.entries()) { + await writeFile( + join(snapshotRoot, "retired.json"), + `${JSON.stringify({ retiredAt: now - gracePeriodMs - index - 1 })}\n`, + ); + } + await activateDevelopmentRuntimeArtifactsSnapshot({ + appRoot, + snapshot: { + runtimeAppRoot: join(activeSnapshotRoot, "source"), + snapshotRoot: activeSnapshotRoot, + snapshotSourceRoot: join(activeSnapshotRoot, "source"), + sourceRoot: appRoot, + }, + }); + + await pruneDevelopmentRuntimeArtifactsSnapshots({ + appRoot, + now, + }); + + expect(existsSync(activeSnapshotRoot)).toBe(true); + expect(existsSync(withinGraceSnapshotRoot)).toBe(true); + for (const snapshotRoot of expiredSnapshotRoots.slice(0, 4)) { + expect(existsSync(snapshotRoot)).toBe(true); + } + for (const snapshotRoot of expiredSnapshotRoots.slice(4)) { + expect(existsSync(snapshotRoot)).toBe(false); } + }); + + it("starts a full grace period for an activated generation from an older format", async () => { + const appRoot = await createScratchDirectory("eve-dev-runtime-legacy-retirement-"); + const snapshotsRoot = join(appRoot, ".eve", "dev-runtime", "snapshots"); + const activeSnapshotRoot = join(snapshotsRoot, "active"); + const legacySnapshotRoot = join(snapshotsRoot, "legacy"); + const now = 1_000_000; + for (const snapshotRoot of [activeSnapshotRoot, legacySnapshotRoot]) { + await mkdir(snapshotRoot, { recursive: true }); + await writeFile(join(snapshotRoot, "activated"), ""); + } await activateDevelopmentRuntimeArtifactsSnapshot({ appRoot, snapshot: { @@ -409,86 +454,67 @@ describe("development runtime artifact snapshots", () => { sourceRoot: appRoot, }, }); - await mkdir(join(appRoot, ".workflow-data", "default", "runs"), { recursive: true }); - await writeFile( - join(appRoot, ".workflow-data", "default", "runs", "parked-turn.json"), - `${JSON.stringify( - { - status: "running", - input: { - serializedContext: { - "eve.bundle": { - source: { - appRoot: join(posixParkedTurnSnapshotRoot, "source", "app").replaceAll("\\", "/"), - kind: "disk", - }, - }, - }, - }, - workflowId: "workflow//eve//turnWorkflow", - }, - null, - 2, - )}\n`, + await pruneDevelopmentRuntimeArtifactsSnapshots({ + appRoot, + gracePeriodMs: 100, + now, + retainCount: 0, + }); + + expect(existsSync(legacySnapshotRoot)).toBe(true); + await expect(readFile(join(legacySnapshotRoot, "retired.json"), "utf8")).resolves.toBe( + `${JSON.stringify({ retiredAt: now })}\n`, ); + + await pruneDevelopmentRuntimeArtifactsSnapshots({ + appRoot, + gracePeriodMs: 100, + now: now + 101, + retainCount: 0, + }); + expect(existsSync(activeSnapshotRoot)).toBe(true); + expect(existsSync(legacySnapshotRoot)).toBe(false); + }); + + it("applies retirement policy without inspecting local Workflow storage", async () => { + const appRoot = await createScratchDirectory("eve-dev-runtime-world-independent-pruning-"); + const snapshotsRoot = join(appRoot, ".eve", "dev-runtime", "snapshots"); + const activeSnapshotRoot = join(snapshotsRoot, "active"); + const retiredSnapshotRoot = join(snapshotsRoot, "retired"); + + for (const snapshotRoot of [activeSnapshotRoot, retiredSnapshotRoot]) { + await mkdir(snapshotRoot, { recursive: true }); + await writeFile(join(snapshotRoot, "activated"), ""); + } await writeFile( - join(appRoot, ".workflow-data", "default", "runs", "parked-turn-windows.json"), - `${JSON.stringify( - { - status: "running", - input: { - serializedContext: { - "eve.bundle": { - source: { - appRoot: join(windowsParkedTurnSnapshotRoot, "source", "app").replaceAll( - "/", - "\\", - ), - kind: "disk", - }, - }, - }, - }, - workflowId: "workflow//eve//turnWorkflow", - }, - null, - 2, - )}\n`, + join(retiredSnapshotRoot, "retired.json"), + `${JSON.stringify({ retiredAt: 1 })}\n`, ); - + await activateDevelopmentRuntimeArtifactsSnapshot({ + appRoot, + snapshot: { + runtimeAppRoot: join(activeSnapshotRoot, "source"), + snapshotRoot: activeSnapshotRoot, + snapshotSourceRoot: join(activeSnapshotRoot, "source"), + sourceRoot: appRoot, + }, + }); + const runsDirectory = join(appRoot, ".workflow-data", "default", "runs"); + await mkdir(runsDirectory, { recursive: true }); await writeFile( - join(appRoot, ".workflow-data", "default", "runs", "completed-turn.json"), - `${JSON.stringify( - { - status: "completed", - input: { - serializedContext: { - "eve.bundle": { - source: { - appRoot: join(completedTurnSnapshotRoot, "source", "app").replaceAll("\\", "/"), - kind: "disk", - }, - }, - }, - }, - workflowId: "workflow//eve//turnWorkflow", - }, - null, - 2, - )}\n`, + join(runsDirectory, "active-turn.json"), + `${JSON.stringify({ snapshotRoot: retiredSnapshotRoot, status: "running" })}\n`, ); + await pruneDevelopmentRuntimeArtifactsSnapshots({ appRoot, - now, - recentWindowMs: 0, + gracePeriodMs: 0, + now: 2, retainCount: 0, }); - await expect(readdir(snapshotsRoot)).resolves.toEqual( - expect.arrayContaining(["active", "parked-turn-posix", "parked-turn-windows"]), - ); - expect(existsSync(completedTurnSnapshotRoot)).toBe(false); - expect(existsSync(staleSnapshotRoot)).toBe(false); + expect(existsSync(activeSnapshotRoot)).toBe(true); + expect(existsSync(retiredSnapshotRoot)).toBe(false); }); it("removes a partially staged snapshot when staging fails", async () => { diff --git a/packages/eve/src/internal/nitro/dev-runtime-artifacts.ts b/packages/eve/src/internal/nitro/dev-runtime-artifacts.ts index 65b386bc2..2640a29f7 100644 --- a/packages/eve/src/internal/nitro/dev-runtime-artifacts.ts +++ b/packages/eve/src/internal/nitro/dev-runtime-artifacts.ts @@ -1,21 +1,21 @@ import { randomUUID } from "node:crypto"; -import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { existsSync, readFileSync, type Dirent } from "node:fs"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve, sep } from "node:path"; import type { CompileAgentResult } from "#compiler/compile-agent.js"; import { copyDevelopmentSourceSnapshot } from "#internal/nitro/dev-runtime-source-snapshot-copy.js"; import { createDevelopmentSourceSnapshotPlan } from "#internal/nitro/dev-runtime-source-snapshot.js"; +import { + DEVELOPMENT_RUNTIME_ARTIFACTS_ACTIVATED_MARKER, + pruneDevelopmentRuntimeArtifactsSnapshotDirectory, + recordRetiredDevelopmentRuntimeArtifactsSnapshot, +} from "#internal/nitro/dev-runtime-artifacts-retention.js"; import { renameWithTransientBusyRetry } from "#shared/rename-with-retry.js"; const DEV_RUNTIME_ARTIFACTS_DIRECTORY = "dev-runtime"; -const DEV_RUNTIME_ARTIFACTS_ACTIVATED_MARKER = "activated"; const DEV_RUNTIME_ARTIFACTS_GENERATION_METADATA = "generation.json"; const DEV_RUNTIME_ARTIFACTS_POINTER_VERSION = 2; -const DEV_RUNTIME_SNAPSHOT_RECENT_WINDOW_MS = 15 * 60 * 1000; -const DEV_RUNTIME_SNAPSHOT_RETAIN_COUNT = 5; -const DEV_RUNTIME_WORKFLOW_DATA_MAX_SCAN_BYTES = 1024 * 1024; -const TERMINAL_WORKFLOW_RUN_STATUSES = new Set(["completed", "failed", "cancelled", "canceled"]); interface DevelopmentRuntimeArtifactsPointerV1 { readonly appRoot: string; @@ -64,6 +64,13 @@ function resolveDevelopmentRuntimeArtifactsSnapshotsDirectory(appRoot: string): return join(appRoot, ".eve", DEV_RUNTIME_ARTIFACTS_DIRECTORY, "snapshots"); } +function isDevelopmentRuntimeArtifactsSnapshotRoot(appRoot: string, snapshotRoot: string): boolean { + return ( + dirname(resolve(snapshotRoot)) === + resolve(resolveDevelopmentRuntimeArtifactsSnapshotsDirectory(appRoot)) + ); +} + /** * Stages one immutable dev runtime snapshot without moving the latest pointer. */ @@ -139,13 +146,30 @@ export async function activateDevelopmentRuntimeArtifactsSnapshotTransaction(inp readonly appRoot: string; readonly snapshot: DevelopmentRuntimeArtifactsSnapshot; }): Promise { - const markerPath = join(input.snapshot.snapshotRoot, DEV_RUNTIME_ARTIFACTS_ACTIVATED_MARKER); + const markerPath = join( + input.snapshot.snapshotRoot, + DEVELOPMENT_RUNTIME_ARTIFACTS_ACTIVATED_MARKER, + ); const pointerPath = resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot); + const previousPointer = readDevelopmentRuntimeArtifactsPointer(pointerPath); const previousPointerSource = await readOptionalFile(pointerPath); try { await writeFile(markerPath, ""); await writeDevelopmentRuntimeArtifactsPointer(input); + if ( + previousPointer?.version === DEV_RUNTIME_ARTIFACTS_POINTER_VERSION && + previousPointer.snapshotRoot !== input.snapshot.snapshotRoot && + isDevelopmentRuntimeArtifactsSnapshotRoot(input.appRoot, previousPointer.snapshotRoot) + ) { + await recordRetiredDevelopmentRuntimeArtifactsSnapshot(previousPointer.snapshotRoot).catch( + (error) => { + console.warn( + `[eve:dev] failed to record retired runtime generation "${previousPointer.snapshotRoot}": ${String(error)}`, + ); + }, + ); + } } catch (error) { throw await rollbackFailedActivation({ cause: error, @@ -225,62 +249,29 @@ export function readDevelopmentRuntimeArtifactsRevision( }; } +/** + * Bounds dev snapshot storage without consulting a Workflow World. The active + * generation is always retained; retired generations receive a grace period, + * and the newest retired generations remain as a rebuild-rate safety net. + */ export async function pruneDevelopmentRuntimeArtifactsSnapshots(input: { readonly appRoot: string; + readonly gracePeriodMs?: number; readonly now?: number; - readonly recentWindowMs?: number; readonly retainCount?: number; }): Promise { - const snapshotsDirectory = resolveDevelopmentRuntimeArtifactsSnapshotsDirectory(input.appRoot); const pointer = readDevelopmentRuntimeArtifactsPointer( resolveDevelopmentRuntimeArtifactsPointerPath(input.appRoot), ); - const protectedPaths = [ - ...collectProtectedSnapshotPaths(pointer), - ...(await collectWorkflowDataSnapshotPaths({ appRoot: input.appRoot, snapshotsDirectory })), - ]; - const now = input.now ?? Date.now(); - const recentWindowMs = input.recentWindowMs ?? DEV_RUNTIME_SNAPSHOT_RECENT_WINDOW_MS; - const retainCount = input.retainCount ?? DEV_RUNTIME_SNAPSHOT_RETAIN_COUNT; - - let entries: Dirent[]; - try { - entries = await readdir(snapshotsDirectory, { withFileTypes: true }); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return; - } - throw error; - } - - const snapshots = ( - await Promise.all( - entries - .filter((entry) => entry.isDirectory()) - .map(async (entry) => { - const path = join(snapshotsDirectory, entry.name); - return { - path, - mtimeMs: (await stat(path)).mtimeMs, - }; - }), - ) - ).sort((left, right) => right.mtimeMs - left.mtimeMs); - - await Promise.all( - snapshots.map(async (snapshot, index) => { - if ( - existsSync(join(snapshot.path, DEV_RUNTIME_ARTIFACTS_ACTIVATED_MARKER)) || - index < retainCount || - now - snapshot.mtimeMs <= recentWindowMs || - protectedPaths.some((protectedPath) => pathsOverlap(snapshot.path, protectedPath)) - ) { - return; - } - - await rm(snapshot.path, { force: true, recursive: true }); - }), - ); + await pruneDevelopmentRuntimeArtifactsSnapshotDirectory({ + activeSnapshotRoot: + pointer?.version === DEV_RUNTIME_ARTIFACTS_POINTER_VERSION ? pointer.snapshotRoot : undefined, + gracePeriodMs: input.gracePeriodMs, + now: input.now, + protectAll: pointer?.version === 1, + retainCount: input.retainCount, + snapshotsDirectory: resolveDevelopmentRuntimeArtifactsSnapshotsDirectory(input.appRoot), + }); } function readDevelopmentRuntimeArtifactsPointer( @@ -337,139 +328,6 @@ function readDevelopmentRuntimeArtifactsPointer( } } -function collectProtectedSnapshotPaths( - pointer: DevelopmentRuntimeArtifactsPointerV1 | DevelopmentRuntimeArtifactsPointerV2 | undefined, -): readonly string[] { - if (pointer === undefined) { - return []; - } - - if (pointer.version === 1) { - return [pointer.appRoot]; - } - - return [pointer.runtimeAppRoot, pointer.snapshotRoot]; -} - -async function collectWorkflowDataSnapshotPaths(input: { - readonly appRoot: string; - readonly snapshotsDirectory: string; -}): Promise { - const workflowDataDirectory = join(input.appRoot, ".workflow-data"); - const snapshotPaths = new Set(); - - await collectSnapshotPathsFromDirectory({ - directory: workflowDataDirectory, - snapshotPaths, - snapshotsDirectory: input.snapshotsDirectory, - }); - - return [...snapshotPaths]; -} - -async function collectSnapshotPathsFromDirectory(input: { - readonly directory: string; - readonly snapshotPaths: Set; - readonly snapshotsDirectory: string; -}): Promise { - let entries: Dirent[]; - - try { - entries = await readdir(input.directory, { withFileTypes: true }); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - return; - } - - throw error; - } - - await Promise.all( - entries.map(async (entry) => { - const path = join(input.directory, entry.name); - - if (entry.isDirectory()) { - await collectSnapshotPathsFromDirectory({ - directory: path, - snapshotPaths: input.snapshotPaths, - snapshotsDirectory: input.snapshotsDirectory, - }); - return; - } - - if (!entry.isFile()) { - return; - } - - const fileStats = await stat(path); - // Local workflow run payloads are small; keep this best-effort scan cheap. - if (fileStats.size > DEV_RUNTIME_WORKFLOW_DATA_MAX_SCAN_BYTES) { - return; - } - - const source = await readFile(path, "utf8"); - if (!shouldScanWorkflowDataSource(source)) { - return; - } - - for (const snapshotPath of collectSnapshotPathsFromText(source, input.snapshotsDirectory)) { - input.snapshotPaths.add(snapshotPath); - } - }), - ); -} - -function collectSnapshotPathsFromText( - source: string, - snapshotsDirectory: string, -): readonly string[] { - const snapshotPaths = new Set(); - const normalizedSnapshotsDirectory = snapshotsDirectory.replaceAll("\\", "/"); - const pattern = new RegExp(`${escapeRegExp(normalizedSnapshotsDirectory)}/([^/"'\\s]+)`, "gu"); - const normalizedSource = source.replaceAll("\\\\", "/").replaceAll("\\", "/"); - - for (const match of normalizedSource.matchAll(pattern)) { - const snapshotName = match[1]; - - if (snapshotName !== undefined && snapshotName.length > 0) { - snapshotPaths.add(join(snapshotsDirectory, snapshotName)); - } - } - - return [...snapshotPaths]; -} - -function shouldScanWorkflowDataSource(source: string): boolean { - const value = parseJsonObject(source); - if (value === undefined) { - return true; - } - - const status = value.status; - return typeof status !== "string" || !TERMINAL_WORKFLOW_RUN_STATUSES.has(status); -} - -function parseJsonObject(source: string): Record | undefined { - try { - const value = JSON.parse(source) as unknown; - return isObjectRecord(value) ? value : undefined; - } catch { - return undefined; - } -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - -function pathsOverlap(left: string, right: string): boolean { - return isPathInsideOrEqual(left, right) || isPathInsideOrEqual(right, left); -} - async function rewriteSnapshotCompiledManifest(input: { readonly appRoot: string; readonly manifestPath: string; diff --git a/packages/eve/src/internal/nitro/development-generation.test.ts b/packages/eve/src/internal/nitro/development-generation.test.ts new file mode 100644 index 000000000..1b51e95b0 --- /dev/null +++ b/packages/eve/src/internal/nitro/development-generation.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { DevelopmentGeneration } from "#internal/nitro/development-generation.js"; + +const mocks = vi.hoisted(() => ({ + activateTransaction: vi.fn(), + prune: vi.fn(async () => undefined), +})); + +vi.mock("#internal/nitro/dev-runtime-artifacts.js", () => ({ + activateDevelopmentRuntimeArtifactsSnapshotTransaction: mocks.activateTransaction, + pruneDevelopmentRuntimeArtifactsSnapshots: mocks.prune, + stageDevelopmentRuntimeArtifactsSnapshot: vi.fn(), +})); + +const { activateDevelopmentGeneration, activateDevelopmentGenerationTransaction } = + await import("#internal/nitro/development-generation.js"); + +function createGeneration(id: string): DevelopmentGeneration { + return { + fingerprint: id, + runtimeAppRoot: `/tmp/${id}/source/app`, + snapshotRoot: `/tmp/${id}`, + snapshotSourceRoot: `/tmp/${id}/source`, + sourceRoot: "/tmp/app", + }; +} + +describe("development generation activation", () => { + beforeEach(() => { + mocks.activateTransaction.mockReset(); + mocks.prune.mockClear(); + }); + + it("requests background storage pruning only after activation commits", async () => { + const commit = vi.fn(); + const rollback = vi.fn(async () => undefined); + mocks.activateTransaction.mockResolvedValue({ commit, rollback }); + + await activateDevelopmentGeneration({ + appRoot: "/tmp/app-commit", + generation: createGeneration("committed"), + }); + + expect(commit).toHaveBeenCalledOnce(); + expect(rollback).not.toHaveBeenCalled(); + expect(mocks.prune).toHaveBeenCalledWith({ appRoot: "/tmp/app-commit" }); + }); + + it("does not request pruning when an activation rolls back", async () => { + const commit = vi.fn(); + const rollback = vi.fn(async () => undefined); + mocks.activateTransaction.mockResolvedValue({ commit, rollback }); + + const activation = await activateDevelopmentGenerationTransaction({ + appRoot: "/tmp/app-rollback", + generation: createGeneration("rolled-back"), + }); + await activation.rollback(); + activation.commit(); + + expect(rollback).toHaveBeenCalledOnce(); + expect(commit).not.toHaveBeenCalled(); + expect(mocks.prune).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/internal/nitro/development-generation.ts b/packages/eve/src/internal/nitro/development-generation.ts index 30b48b27d..1379e9f19 100644 --- a/packages/eve/src/internal/nitro/development-generation.ts +++ b/packages/eve/src/internal/nitro/development-generation.ts @@ -3,8 +3,8 @@ import { rm } from "node:fs/promises"; import type { CompileAgentResult } from "#compiler/compile-agent.js"; import { materializeAuthoredModules } from "#internal/materialized-authored-modules.js"; import { - activateDevelopmentRuntimeArtifactsSnapshot, activateDevelopmentRuntimeArtifactsSnapshotTransaction, + pruneDevelopmentRuntimeArtifactsSnapshots, stageDevelopmentRuntimeArtifactsSnapshot, type DevelopmentRuntimeArtifactsActivation, type DevelopmentRuntimeArtifactsSnapshot, @@ -14,6 +14,13 @@ export interface DevelopmentGeneration extends DevelopmentRuntimeArtifactsSnapsh readonly fingerprint: string; } +interface DevelopmentGenerationPruneState { + requested: boolean; + running: Promise | undefined; +} + +const developmentGenerationPruneStates = new Map(); + export async function stageDevelopmentGeneration( compileResult: CompileAgentResult, ): Promise { @@ -59,20 +66,36 @@ export async function activateDevelopmentGeneration(input: { readonly appRoot: string; readonly generation: DevelopmentGeneration; }): Promise { - await activateDevelopmentRuntimeArtifactsSnapshot({ - appRoot: input.appRoot, - snapshot: input.generation, - }); + const activation = await activateDevelopmentGenerationTransaction(input); + activation.commit(); } export async function activateDevelopmentGenerationTransaction(input: { readonly appRoot: string; readonly generation: DevelopmentGeneration; }): Promise { - return await activateDevelopmentRuntimeArtifactsSnapshotTransaction({ + const activation = await activateDevelopmentRuntimeArtifactsSnapshotTransaction({ appRoot: input.appRoot, snapshot: input.generation, }); + let settled = false; + return { + commit() { + if (settled) { + return; + } + settled = true; + activation.commit(); + requestDevelopmentGenerationPrune(input.appRoot); + }, + async rollback() { + if (settled) { + return; + } + settled = true; + await activation.rollback(); + }, + }; } export async function discardDevelopmentGeneration( @@ -80,3 +103,38 @@ export async function discardDevelopmentGeneration( ): Promise { await rm(generation.snapshotRoot, { force: true, recursive: true }); } + +function requestDevelopmentGenerationPrune(appRoot: string): void { + const state = developmentGenerationPruneStates.get(appRoot) ?? { + requested: false, + running: undefined, + }; + developmentGenerationPruneStates.set(appRoot, state); + state.requested = true; + if (state.running === undefined) { + startDevelopmentGenerationPruning(appRoot, state); + } +} + +function startDevelopmentGenerationPruning( + appRoot: string, + state: DevelopmentGenerationPruneState, +): void { + state.running = (async () => { + while (state.requested) { + state.requested = false; + await pruneDevelopmentRuntimeArtifactsSnapshots({ appRoot }); + } + })() + .catch((error) => { + console.warn(`[eve:dev] failed to prune runtime generations: ${String(error)}`); + }) + .finally(() => { + state.running = undefined; + if (state.requested) { + startDevelopmentGenerationPruning(appRoot, state); + } else { + developmentGenerationPruneStates.delete(appRoot); + } + }); +}