From 940c963a278362a71617397624571ef3532559d8 Mon Sep 17 00:00:00 2001 From: devSejung Date: Wed, 4 Nov 2026 16:48:53 +0900 Subject: [PATCH] fix: own and clean disposable development task resources --- PLATFORMCLAW.md | 13 + scripts/AGENTS.md | 21 + scripts/README.md | 124 ++++ scripts/platformclaw-check.mjs | 4 + scripts/platformclaw-ci-plan.mjs | 3 + scripts/platformclaw-dev-cleanup.mjs | 25 +- scripts/platformclaw-dev-maintenance.ps1 | 39 +- scripts/platformclaw-dev-task.mjs | 684 ++++++++++++++++++ test/scripts/platformclaw-check.test.ts | 10 + test/scripts/platformclaw-dev-task.test.ts | 399 ++++++++++ .../platformclaw-runtime-docker.test.ts | 3 +- 11 files changed, 1311 insertions(+), 14 deletions(-) create mode 100644 scripts/platformclaw-dev-task.mjs create mode 100644 test/scripts/platformclaw-dev-task.test.ts diff --git a/PLATFORMCLAW.md b/PLATFORMCLAW.md index 50fad0abc9e..1d360096753 100644 --- a/PLATFORMCLAW.md +++ b/PLATFORMCLAW.md @@ -77,6 +77,19 @@ These areas are candidates for future evaluation and migration. They are not an Bring upstream changes from `upstream/main` into a sync branch, validate them there, and merge them into `main` through a PR. +## Development resource lifecycle + +Keep one primary checkout per developer. Create task-owned linked worktrees with +`node scripts/platformclaw-dev-task.mjs create --branch fix/` instead +of making another independent clone for each task. Use `run` for bounded commands, +`image` for disposable validation images, and `finish --apply` when the work +is complete. The [developer workflow](scripts/README.md#platformclaw-development-resources) +includes dependency reuse, interrupted-task recovery, and maintenance setup. + +Managed cleanup never adopts old folders by name or age. Audit existing clones +and worktrees before removing them. Product data, volumes, active tasks, and +uncommitted or unrecognized files remain protected; a blocked cleanup reports why. + ## Current Phase Production Docker deployment migration and validation remains in progress. diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index c6fbc35cd33..1f0006f06b7 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -30,6 +30,27 @@ This directory owns local tooling, script wrappers, and generated-artifact helpe - If a script writes generated artifacts, keep the source-of-truth generator, the package script, and the matching verification/check command aligned. - Prefer additive generator/check pairs like `*:gen` and `*:check` over one-off undocumented scripts. +## PlatformClaw Development Resources + +- Keep one primary clone. Prefer `platformclaw-dev-task.mjs create` for task + worktrees over independent clones or ad hoc source copies. Use the managed + task's scratch directory for disposable inputs, not unrelated host folders. +- Run bounded commands through `platformclaw-dev-task.mjs run`; build disposable + proof images through its `image` command. An exported task prefix alone does + not make arbitrary Docker resources managed. Do not daemonize child commands. +- Finish tasks explicitly with `finish --apply`, including failed work. + Periodic `platformclaw-dev-cleanup.mjs` sweeps only completed managed tasks. + Unknown or interrupted ownership is a visible stop condition, not an age-based + deletion opportunity. Confirm the exact task and its descendants stopped + before using `recover --confirm-stopped`. +- Never force-remove dirty, untracked, ignored, or container-referenced resources. + Keep the PR wrapper's worktree GC and operation locks under `scripts/pr`; + do not bypass them through the managed-task cleanup path. +- Reuse repository toolchain wrappers for compatible focused host checks. Never + install dependencies in Windows linked worktrees. Shared download caches and + junctions are not equivalent to duplicate physical dependency trees; isolated + Linux checks keep dependencies on Linux-owned filesystems. + ## Scope - Keep script-runner behavior, wrapper expectations, and generated-artifact guidance here. diff --git a/scripts/README.md b/scripts/README.md index 490466694a6..d19c57cca86 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -72,4 +72,128 @@ new directory taxonomy. - Add or update focused tests under `test/scripts/` when changing script behavior. +## PlatformClaw Development Resources + +Use one primary checkout per developer and Node 24.15 or newer. Managed task +commands run on Windows or Linux; on other hosts use a Linux development +environment for those commands. The resource +tool itself needs only Node and Git; it works before package installation. +Install the +repository's pinned dependencies once in the primary checkout when host +installation is permitted. Never run `pnpm install` in a Windows linked worktree. +Focused host checks use existing repository wrappers to reuse a compatible +primary toolchain; heavy checks use local Linux Docker or healthy WSL with +Linux-owned source and dependencies, following the root validation policy. +Share package download caches, not arbitrary `node_modules` directories across +different lockfiles or runtimes. A junction points at an existing installation; +counting its target again overstates physical disk use. + +From the primary checkout, create a task instead of another independent clone: + +```sh +git fetch origin main +node scripts/platformclaw-dev-task.mjs create login-fix --branch fix/login-fix --ref origin/main +node scripts/platformclaw-dev-task.mjs list +``` + +`create` prints the worktree path under `.worktrees/`; open that path in your +editor. The task has its own scratch directory under `.artifacts/dev-tasks/`. +All linked invocations share one development-only SQLite registry in the Git +common directory. No Gateway or agent database is used. + +Run a bounded command from the primary checkout against that task's source: + +```sh +node scripts/platformclaw-dev-task.mjs run login-fix -- node scripts/run-vitest.mjs test/scripts/platformclaw-dev-task.test.ts +``` + +The command receives task-local `TMP`, `TEMP`, and `TMPDIR`. Normal command +success and failure both join the child and clean its disposable scratch files; +the source worktree remains for further edits. Do not use `run` to launch +detached servers or other background work. Keep any result you need outside the +temporary directory and exclude private data from committed evidence. + +Build disposable Docker images through the task owner: + +```sh +node scripts/platformclaw-dev-task.mjs image login-fix proof -- -f Dockerfile.proof . +``` + +Replace the Dockerfile and context with your actual validation inputs; pass +short flags separately (`-f Dockerfile`, not grouped or attached forms). `image` +prints its task-specific tag and adds ownership metadata. Direct `docker build`, +independent clones, arbitrary temporary folders, and preexisting resources are +not automatically adopted. Release images and release archives remain under +the release builder's retention policy, not the disposable-proof lifecycle. +Use `docker run --rm` for bounded proof containers. The task tool never stops +containers or deletes volumes; a remaining container reference blocks cleanup. + +After committing or discarding your intended edits, finish from the primary +checkout. Preview first, then apply: + +```sh +node scripts/platformclaw-dev-task.mjs finish login-fix +node scripts/platformclaw-dev-task.mjs finish login-fix --apply +node scripts/platformclaw-dev-task.mjs list +``` + +Finishing deletes only the task's disposable resources and a clean worktree; +the Git branch remains. Dirty, untracked, ignored, or container-referenced +resources are retained with an explanation. Remove or relocate those files +deliberately, then retry. Do not use force deletion to make cleanup appear clean. +Periodic sweeping retries finished tasks; it never completes active tasks for +you or removes product volumes and databases. + +A killed wrapper or uncertain child outcome is not normal completion. First +verify the original command and all of its descendants stopped, then recover +that exact task and finish it: + +```sh +node scripts/platformclaw-dev-task.mjs recover login-fix --confirm-stopped +node scripts/platformclaw-dev-task.mjs finish login-fix --apply +``` + +Inspect legacy independent clones and unmanaged worktrees without deleting them: + +```sh +node scripts/platformclaw-dev-task.mjs audit +node scripts/platformclaw-dev-cleanup.mjs --skip-final-images --skip-archives +``` + +Classify legacy folders separately using their Git state, merged status, recent +changes, and actual running consumers. An old timestamp or a matching folder +name alone is not deletion authority. + +### Windows maintenance setup + +After updating the primary checkout to include this tooling, install or refresh +the per-user task there; installation from a removable worktree is rejected: + +```powershell +.\scripts\platformclaw-dev-maintenance.ps1 -Action Install +.\scripts\platformclaw-dev-maintenance.ps1 -Action Status +``` + +The task runs every four hours while that user is signed in, using the same +bounded options as `-Action Run`: managed-task sweeping, release intermediates, +and a BuildKit cache target of 20 GB with 5 GB reserved and a 20 GB free-space +target. Final release images and archives are skipped; their lifecycle remains +with the release builder. `Status` shows the last result and next run. Read +cleanup output for skipped or blocked work; a scheduled-task result alone does +not prove every resource was reclaimed. + +For manual maintenance or hosts without Windows Task Scheduler: + +```sh +node scripts/platformclaw-dev-cleanup.mjs --apply --skip-final-images --skip-archives --cache-max 20gb --cache-reserved 5gb --cache-min-free 20gb +``` + +The 20 GB target applies to BuildKit cache, not total Docker storage. Images, +container writable layers, volumes, and exported archives are separate. Docker +internal free space also does not necessarily shrink Docker Desktop's VHDX. +Compaction is a separate, elevated, explicitly scheduled downtime operation: +only after Docker and WSL work has stopped, run +`./scripts/platformclaw-docker-vhdx-compact.ps1 -Apply`. It stops Docker Desktop +and WSL; routine maintenance never does this automatically. + See also [Scripts](https://docs.openclaw.ai/help/scripts) for public-facing script guidance. diff --git a/scripts/platformclaw-check.mjs b/scripts/platformclaw-check.mjs index ab0be8d0f60..5a997892a33 100644 --- a/scripts/platformclaw-check.mjs +++ b/scripts/platformclaw-check.mjs @@ -111,6 +111,9 @@ const SURFACE_COMMANDS = { "scripts/platformclaw-check.mjs", "scripts/platformclaw-ci-plan.d.mts", "scripts/platformclaw-ci-plan.mjs", + "scripts/platformclaw-dev-task.mjs", + "scripts/platformclaw-dev-cleanup.mjs", + "test/scripts/platformclaw-dev-task.test.ts", "test/scripts/platformclaw-check.test.ts", "test/scripts/platformclaw-ci-plan.test.ts", ]), @@ -120,6 +123,7 @@ const SURFACE_COMMANDS = { "test/scripts/platformclaw-check.test.ts", "test/scripts/platformclaw-ci-plan.test.ts", "test/scripts/platformclaw-runtime-docker.test.ts", + "test/scripts/platformclaw-dev-task.test.ts", ]), ], ui: [ diff --git a/scripts/platformclaw-ci-plan.mjs b/scripts/platformclaw-ci-plan.mjs index 75d22a0a0e0..056d9710d9e 100644 --- a/scripts/platformclaw-ci-plan.mjs +++ b/scripts/platformclaw-ci-plan.mjs @@ -69,6 +69,9 @@ export function classifyPlatformClawChanges(inputFiles) { file === "scripts/platformclaw-ci-plan.d.mts" || file === "scripts/platformclaw-check.mjs" || file === "scripts/platformclaw-check.d.mts" || + file === "scripts/platformclaw-dev-task.mjs" || + file === "scripts/platformclaw-dev-cleanup.mjs" || + file === "scripts/platformclaw-dev-maintenance.ps1" || file.startsWith("test/scripts/platformclaw-"), ); const hasWorkflowChanges = files.some((file) => diff --git a/scripts/platformclaw-dev-cleanup.mjs b/scripts/platformclaw-dev-cleanup.mjs index 83129dd17b6..7d149a650a6 100644 --- a/scripts/platformclaw-dev-cleanup.mjs +++ b/scripts/platformclaw-dev-cleanup.mjs @@ -129,7 +129,7 @@ function docker(args, { allowFailure = false } = {}) { } function restoreImageTag(tag, imageId) { - const args = imageId ? ["image", "tag", imageId, tag] : ["image", "rm", tag]; + const args = imageId ? ["image", "tag", imageId, tag] : ["image", "rm", "--no-prune", tag]; const result = docker(args, { allowFailure: true }); if (result.status !== 0 && !/No such image/u.test(result.stderr)) { fail(result.stderr.trim() || `Failed to restore image tag ${tag}`); @@ -291,7 +291,7 @@ function removeIntermediateImages(options, used) { } const tag = `${row.Repository}:${row.Tag}`; announce(options, `remove intermediate image ${tag}`, () => { - docker(["image", "rm", tag]); + docker(["image", "rm", "--no-prune", tag]); }); } } @@ -311,7 +311,7 @@ function removeFailedFinalImages(options, used) { } const tag = `${row.Repository}:${row.Tag}`; announce(options, `remove unvalidated final image ${tag}`, () => { - docker(["image", "rm", tag]); + docker(["image", "rm", "--no-prune", tag]); }); } } @@ -349,7 +349,7 @@ function pruneRepository(options, repository, used) { } const tag = `${row.Repository}:${row.Tag}`; announce(options, `remove old rollback tag ${tag}`, () => { - docker(["image", "rm", tag]); + docker(["image", "rm", "--no-prune", tag]); }); } } @@ -522,10 +522,25 @@ function pruneBuildCache(options) { } const options = readArgs(process.argv.slice(2)); +// Host task ownership is independent of Docker availability. Sweep before the +// engine lock, so a stopped Desktop does not strand completed source worktrees. +const taskSweep = spawnSync( + process.execPath, + [ + resolve(repoRoot, "scripts", "platformclaw-dev-task.mjs"), + "sweep", + ...(options.apply ? ["--apply"] : []), + ], + { cwd: repoRoot, stdio: "inherit" }, +); +if (taskSweep.error || taskSweep.status !== 0) { + console.error("Managed task cleanup is blocked; inspect platformclaw-dev-task.mjs list"); + process.exitCode = 1; +} const cleanupLock = await acquireCleanupLock(options); if (!cleanupLock) { console.log("SKIP: a PlatformClaw build is active"); - process.exit(0); + process.exit(process.exitCode ?? 0); } const outputDirectoryLock = await acquireOutputDirectoryLock(options); try { diff --git a/scripts/platformclaw-dev-maintenance.ps1 b/scripts/platformclaw-dev-maintenance.ps1 index 7622c818508..f383b7ed6be 100644 --- a/scripts/platformclaw-dev-maintenance.ps1 +++ b/scripts/platformclaw-dev-maintenance.ps1 @@ -7,21 +7,32 @@ $ErrorActionPreference = "Stop" $taskName = "PlatformClaw Home Dev Cleanup" $repoRoot = Split-Path -Parent $PSScriptRoot $cleanupScript = Join-Path $PSScriptRoot "platformclaw-dev-cleanup.mjs" +$cleanupArgs = @( + "--apply", "--skip-final-images", "--skip-archives", + "--cache-max", "20gb", "--cache-reserved", "5gb", "--cache-min-free", "20gb" +) if ($Action -eq "Run") { - & node $cleanupScript --apply + & node $cleanupScript @cleanupArgs if ($LASTEXITCODE -ne 0) { throw "PlatformClaw cleanup failed ($LASTEXITCODE)" } exit 0 } if ($Action -eq "Install") { + if ((Get-Item -LiteralPath (Join-Path $repoRoot ".git") -Force).PSIsContainer -eq $false) { + throw "Install maintenance from the primary checkout after updating it, not from a removable linked worktree." + } $node = (Get-Command node -ErrorAction Stop).Source - $arguments = '"{0}" --apply' -f $cleanupScript + $arguments = '"{0}" {1}' -f $cleanupScript, ($cleanupArgs -join " ") $taskAction = New-ScheduledTaskAction -Execute $node -Argument $arguments -WorkingDirectory $repoRoot - $trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 3am - $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 2) - Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Settings $settings -Description "Weekly bounded cleanup for PlatformClaw home development Docker data" -Force | Out-Null - Write-Host "Installed weekly task: $taskName" + $trigger = New-ScheduledTaskTrigger -Daily -At 3am + # Daily triggers do not expose repetition parameters, but accept the same + # scheduler repetition pattern as a once trigger. Renew it every day. + $repeating = New-ScheduledTaskTrigger -Once -At 3am -RepetitionInterval (New-TimeSpan -Hours 4) -RepetitionDuration (New-TimeSpan -Days 1) + $trigger.Repetition = $repeating.Repetition + $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 2) + Register-ScheduledTask -TaskName $taskName -Action $taskAction -Trigger $trigger -Settings $settings -Description "Every-four-hour bounded cleanup for PlatformClaw development resources" -Force | Out-Null + Write-Host "Installed every-four-hour task: $taskName" exit 0 } @@ -31,5 +42,17 @@ if ($Action -eq "Uninstall") { exit 0 } -Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue | - Select-Object TaskName, State, Description +$task = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue +if ($null -eq $task) { + Write-Host "Not installed: $taskName" + exit 0 +} +$info = $task | Get-ScheduledTaskInfo +[pscustomobject]@{ + TaskName = $task.TaskName + State = $task.State + LastRunTime = $info.LastRunTime + LastTaskResult = $info.LastTaskResult + NextRunTime = $info.NextRunTime + Description = $task.Description +} diff --git a/scripts/platformclaw-dev-task.mjs b/scripts/platformclaw-dev-task.mjs new file mode 100644 index 00000000000..a7b62d74399 --- /dev/null +++ b/scripts/platformclaw-dev-task.mjs @@ -0,0 +1,684 @@ +#!/usr/bin/env node + +// Development-only resource ownership. Product runtime databases are never opened here. +import { spawn, spawnSync } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import process from "node:process"; +import { DatabaseSync } from "node:sqlite"; + +function command(executable, args, cwd = process.cwd()) { + const result = spawnSync(executable, args, { + cwd, + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + if (result.error || result.status !== 0) { + throw new Error( + result.error?.message || result.stderr.trim() || `${executable} failed (${result.status})`, + ); + } + return result.stdout.trim(); +} + +function primaryRoot(cwd) { + const common = realpathSync( + command("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], cwd), + ); + if (basename(common) !== ".git") { + throw new Error( + "Use a normal primary checkout or its linked worktree (bare repositories are unsupported)", + ); + } + return dirname(common); +} + +let root; +let databasePath; +let database; + +function openRegistry() { + if (database) { + return; + } + // Bootstrap-only SQLite primitive: workspace setup/cleanup must work before + // package installation. Fixed parameterized operations below never access + // product databases or introduce a second runtime persistence path. + safePath(databasePath); + database = new DatabaseSync(databasePath); + database.exec(` + PRAGMA busy_timeout = 5000; + CREATE TABLE IF NOT EXISTS dev_tasks ( + name TEXT PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + branch TEXT NOT NULL, + start_sha TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('active', 'running', 'incomplete', 'finished', 'cleaned')), + note TEXT NOT NULL, + created_at TEXT NOT NULL, + docker_engine TEXT + ); + `); +} + +function tasks() { + openRegistry(); + return database.prepare("SELECT * FROM dev_tasks ORDER BY created_at").all(); +} + +function taskByName(name) { + if (!/^[a-z0-9][a-z0-9-]{0,47}$/u.test(name ?? "")) { + throw new Error("Task name must contain 1–48 lowercase letters, digits, or hyphens"); + } + openRegistry(); + const task = database.prepare("SELECT * FROM dev_tasks WHERE name = ?").get(name); + if (!task) { + throw new Error(`Unknown task: ${name}`); + } + return task; +} + +function update(task, values) { + // A command may have already recorded its Docker engine. Reread under the + // task lock so a later state-only update cannot erase that ownership fact. + const current = taskByName(task.name); + if (current.token !== task.token) { + throw new Error("Task ownership changed; refusing registry update"); + } + database + .prepare( + "UPDATE dev_tasks SET state = ?, note = ?, docker_engine = ? WHERE name = ? AND token = ?", + ) + .run( + values.state ?? current.state, + values.note ?? current.note, + values.docker_engine ?? current.docker_engine, + task.name, + task.token, + ); +} + +function paths(task) { + if (!/^[0-9a-f]{32}$/u.test(task.token) || !/^[a-z0-9][a-z0-9-]{0,47}$/u.test(task.name)) { + throw new Error("Invalid task identity in developer registry; refusing cleanup"); + } + return { + workspace: join(root, ".worktrees", `dev-${task.name}-${task.token.slice(0, 8)}`), + scratch: join(root, ".artifacts", "dev-tasks", task.token), + repository: `platformclaw-dev-${task.token}`, + }; +} + +function safePath(target) { + const local = relative(root, target); + if (!local || local === ".." || local.startsWith(`..${sep}`) || resolve(root, local) !== target) { + throw new Error(`Refusing path outside the managed checkout: ${target}`); + } + let cursor = root; + for (const part of local.split(sep)) { + cursor = join(cursor, part); + const stat = lstatSync(cursor, { throwIfNoEntry: false }); + if (stat?.isSymbolicLink()) { + throw new Error(`Refusing managed path through a symlink/junction: ${cursor}`); + } + } +} + +async function locked(name, action) { + const key = createHash("sha256").update(`${root}\0${name}`).digest("hex"); + // OS-owned endpoints disappear with the controller, without stale PID files + // or collision-prone TCP ports in the host's outbound ephemeral range. + const path = + process.platform === "win32" + ? `\\\\.\\pipe\\platformclaw-dev-${key}` + : `\0platformclaw-dev-${key}`; + if (process.platform !== "win32" && process.platform !== "linux") { + throw new Error("Managed tasks require Windows or Linux; use local Docker/WSL on other hosts"); + } + const server = createServer(); + try { + await new Promise((accept, reject) => { + server.once("error", reject); + server.listen({ path, exclusive: true }, accept); + }); + } catch (error) { + if (error.code === "EADDRINUSE") { + throw new Error(`Task ${name} is busy; run/image/finish/sweep never overlap`, { + cause: error, + }); + } + throw error; + } + try { + return await action(); + } finally { + await new Promise((accept, reject) => { + server.close((error) => + error + ? reject(new Error("Failed to release developer task lock", { cause: error })) + : accept(), + ); + }); + } +} + +function assertUsable(task) { + if (task.state !== "active") { + throw new Error( + `Task ${task.name} is ${task.state}: ${task.note}. Inspect list; interrupted commands require recover --confirm-stopped.`, + ); + } + const { workspace } = paths(task); + safePath(workspace); + if ( + realpathSync(command("git", ["rev-parse", "--show-toplevel"], workspace)) !== workspace || + primaryRoot(workspace) !== root + ) { + throw new Error("Task workspace no longer belongs to this checkout"); + } +} + +function scratchCleanup(task, apply) { + const { scratch } = paths(task); + safePath(scratch); + console.log(`${apply ? "APPLY" : "PREVIEW"}: remove task scratch ${scratch}`); + if (apply) { + rmSync(scratch, { recursive: true, force: true }); + } +} + +function taskEnvironment(task) { + const { scratch, repository } = paths(task); + safePath(scratch); + const tmp = join(scratch, "tmp"); + mkdirSync(tmp, { recursive: true }); + return { + ...process.env, + TMP: tmp, + TEMP: tmp, + TMPDIR: tmp, + PLATFORMCLAW_DEV_IMAGE_REPOSITORY: repository, + }; +} + +function assertVisibleIndex(workspace) { + const entries = command("git", ["ls-files", "-v", "-z"], workspace).split("\0").filter(Boolean); + for (const entry of entries) { + const flag = entry[0]; + if (flag !== "S" && flag === flag.toUpperCase()) { + continue; + } + const path = entry.slice(2); + const indexed = command("git", ["ls-files", "--stage", "--", path], workspace); + // Windows' tracked junction guard intentionally hides node_modules symlink + // placeholders. Permit only those exact unchanged index blobs, never other + // skip-worktree/assume-unchanged files that status and worktree remove miss. + const guardedLink = + flag === "S" && indexed.startsWith("120000 ") && path.split("/").includes("node_modules"); + if (!guardedLink) { + throw new Error( + `Index flags hide tracked work: ${path}; clear its assume-unchanged/skip-worktree flag and inspect the file`, + ); + } + const full = join(workspace, path); + safePath(dirname(full)); + const stat = lstatSync(full, { throwIfNoEntry: false }); + if (!stat) { + continue; + } + const expected = command("git", ["show", `:${path}`], workspace); + const actual = + stat.isSymbolicLink() && process.platform !== "win32" + ? readlinkSync(full) + : stat.isFile() + ? readFileSync(full, "utf8") + : undefined; + if (actual !== expected) { + throw new Error( + `Tracked dependency link changed or became a junction: ${path}; refusing removal`, + ); + } + } +} + +function assertCleanWorkspace(task) { + const { workspace } = paths(task); + safePath(workspace); + if ( + primaryRoot(workspace) !== root || + realpathSync(command("git", ["rev-parse", "--show-toplevel"], workspace)) !== workspace || + command("git", ["branch", "--show-current"], workspace) !== task.branch + ) { + throw new Error("Task worktree identity changed; refusing removal"); + } + const dirty = command("git", ["status", "--porcelain", "--untracked-files=normal"], workspace); + assertVisibleIndex(workspace); + const ignored = command( + "git", + ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory"], + workspace, + ); + if (dirty || ignored) { + throw new Error( + `Worktree contains changes, untracked files, or ignored files: ${workspace}. Preserve/commit them and retry finish; no force deletion is supported.`, + ); + } +} + +async function cleanupTask(task, apply) { + const { workspace, repository } = paths(task); + safePath(workspace); + if (task.docker_engine) { + if (command("docker", ["info", "--format", "{{.ID}}"]) !== task.docker_engine) { + throw new Error("Task Docker engine changed; switch to the original engine before cleanup"); + } + const ids = command("docker", ["image", "ls", repository, "--no-trunc", "--quiet"]) + .split(/\s+/u) + .filter(Boolean); + for (const id of new Set(ids)) { + if (command("docker", ["ps", "-aq", "--filter", `ancestor=${id}`])) { + throw new Error( + `Container still references task image ${id}; stop only your task's containers before finishing`, + ); + } + } + for (const id of new Set(ids)) { + const image = JSON.parse(command("docker", ["image", "inspect", id]))[0]; + if (image.Config.Labels?.["io.platformclaw.dev.task"] !== task.token) { + throw new Error(`Image ownership changed for ${id}; refusing cleanup`); + } + const tags = image.RepoTags ?? []; + const owned = tags.filter((tag) => tag.startsWith(`${repository}:`)); + // Unique build tags avoid orphaning previous results. Never delete an + // untagged image merely because a descendant inherited its labels. + for (const tag of owned) { + console.log(`${apply ? "APPLY" : "PREVIEW"}: remove task image ${tag}`); + if (apply) { + const current = JSON.parse(command("docker", ["image", "inspect", tag]))[0]; + if ( + current.Id !== id || + current.Config.Labels?.["io.platformclaw.dev.task"] !== task.token || + command("docker", ["ps", "-aq", "--filter", `ancestor=${id}`]) + ) { + throw new Error( + `Image ownership or container usage changed for ${tag}; refusing removal`, + ); + } + command("docker", ["image", "rm", "--no-prune", tag]); + } + } + } + } + scratchCleanup(task, apply); + if (existsSync(workspace)) { + assertCleanWorkspace(task); + console.log( + `${apply ? "APPLY" : "PREVIEW"}: remove clean worktree ${workspace} (branch retained)`, + ); + if (apply) { + await locked("git-mutations", () => { + // Another managed task can create/remove a sibling while this task runs. + // Serialize shared Git metadata changes, not the validation commands. + assertCleanWorkspace(task); + command("git", ["worktree", "remove", workspace], root); + }); + } + } + if (apply) { + update(task, { state: "cleaned", note: "Disposable resources removed; Git branch retained" }); + } +} + +async function createTask(name, args) { + if (!/^[a-z0-9][a-z0-9-]{0,47}$/u.test(name ?? "")) { + throw new Error("Invalid task name"); + } + let branch; + let ref = "origin/main"; + for (let index = 0; index < args.length; index += 2) { + if (args[index] === "--branch") { + branch = args[index + 1]; + } else if (args[index] === "--ref") { + ref = args[index + 1]; + } else { + throw new Error(`Unknown create argument: ${args[index]}`); + } + } + if ( + !/^(?:fix|feature|refactor)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(branch ?? "") || + !ref || + ref.startsWith("-") + ) { + throw new Error( + "create requires --branch fix/name, feature/name, or refactor/name and a valid --ref", + ); + } + command("git", ["check-ref-format", "--branch", branch], root); + const sha = command("git", ["rev-parse", "--verify", `${ref}^{commit}`], root); + openRegistry(); + const task = { + name, + token: randomBytes(16).toString("hex"), + branch, + start_sha: sha, + state: "incomplete", + note: "Workspace creation did not complete", + created_at: new Date().toISOString(), + docker_engine: null, + }; + // Record identity before creation: even an interrupted checkout has a visible owner. + database + .prepare( + "INSERT INTO dev_tasks (name, token, branch, start_sha, state, note, created_at, docker_engine) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + ) + .run( + task.name, + task.token, + task.branch, + task.start_sha, + task.state, + task.note, + task.created_at, + task.docker_engine, + ); + const { workspace, scratch } = paths(task); + safePath(workspace); + safePath(scratch); + mkdirSync(dirname(workspace), { recursive: true }); + await locked("git-mutations", () => { + command("git", ["worktree", "add", "-b", branch, workspace, sha], root); + command("git", ["sparse-checkout", "disable"], workspace); + }); + mkdirSync(join(scratch, "tmp"), { recursive: true }); + update(task, { state: "active", note: "Workspace ready; no dependencies installed" }); + console.log(workspace); +} + +async function runTask(task, args) { + assertUsable(task); + if (args[0] !== "--" || args.length < 2) { + throw new Error( + "run requires -- [arguments...]; use a shell explicitly for shell commands", + ); + } + const { workspace } = paths(task); + const env = taskEnvironment(task); + update(task, { + state: "running", + note: "Command may still have descendants; explicit recovery required if interrupted", + }); + let interrupted = false; + const child = spawn(args[1], args.slice(2), { + cwd: workspace, + stdio: "inherit", + env, + }); + const interrupt = (signal) => { + interrupted = true; + child.kill(signal); + }; + const onInt = () => interrupt("SIGINT"); + const onTerm = () => interrupt("SIGTERM"); + process.on("SIGINT", onInt); + process.on("SIGTERM", onTerm); + try { + const outcome = await new Promise((accept, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => accept({ code, signal })); + }); + if (interrupted || outcome.signal || outcome.code === null) { + throw new Error("Command interrupted; resources preserved until recover --confirm-stopped"); + } + scratchCleanup(task, true); + update(task, { + state: "active", + note: `Last command exited ${outcome.code}; source retained until finish`, + }); + return outcome.code; + } catch (error) { + update(task, { + state: "incomplete", + note: "Command interrupted or cleanup failed; inspect resources before recovery", + }); + throw error; + } finally { + process.off("SIGINT", onInt); + process.off("SIGTERM", onTerm); + } +} + +function buildImage(task, args) { + assertUsable(task); + const [role, separator, ...buildArgs] = args; + if (!/^[a-z0-9][a-z0-9-]{0,47}$/u.test(role ?? "") || separator !== "--" || !buildArgs.length) { + throw new Error("image requires -- "); + } + if ( + buildArgs.some( + (arg) => + /^(?:-t.*|-o.*|--(?:tag|output|push|load|iidfile|metadata-file)(?:=|$))/u.test(arg) || + (arg.startsWith("-") && !arg.startsWith("--") && arg !== "-" && arg.length !== 2), + ) + ) { + throw new Error( + "Managed image builds own their tags and local output; do not pass tag/output/push options or grouped/attached short flags (use -f Dockerfile, not -fDockerfile)", + ); + } + const engine = command("docker", ["info", "--format", "{{.ID}}"]); + if (task.docker_engine && task.docker_engine !== engine) { + throw new Error("Task belongs to a different Docker engine"); + } + const tag = `${paths(task).repository}:${role}-${randomBytes(6).toString("hex")}`; + const env = taskEnvironment(task); + update(task, { + docker_engine: engine, + state: "running", + note: "Image build may still be active; recover only after confirming it stopped", + }); + // Append ownership labels after caller options; namespace tags never alias a release image. + const result = spawnSync( + "docker", + ["build", ...buildArgs, "--label", `io.platformclaw.dev.task=${task.token}`, "--tag", tag], + { cwd: paths(task).workspace, stdio: "inherit", env }, + ); + if (result.error || result.signal || result.status === null) { + update(task, { + state: "incomplete", + note: "Image build interrupted; inspect Docker before recovery", + }); + throw new Error("Image build interrupted; resources preserved"); + } + if (result.status === 0) { + try { + const image = JSON.parse(command("docker", ["image", "inspect", tag]))[0]; + if ( + command("docker", ["info", "--format", "{{.ID}}"]) !== engine || + image.Config.Labels?.["io.platformclaw.dev.task"] !== task.token + ) { + throw new Error("Task image ownership/engine did not match the build"); + } + } catch (error) { + update(task, { + state: "incomplete", + note: "Docker returned success without a verified local task image; inspect the builder before recovery", + }); + throw new Error("No verified task image was produced; resources preserved", { cause: error }); + } + } + try { + scratchCleanup(task, true); + } catch (error) { + update(task, { + state: "incomplete", + note: "Image build joined but scratch cleanup failed; inspect before recovery", + }); + throw error; + } + update(task, { + state: "active", + note: `Image build exited ${result.status}; finish removes owned image tags`, + }); + console.log( + result.status === 0 + ? `Task image: ${tag}` + : `Image build failed (exit ${result.status}); finish the task to clean disposable resources`, + ); + return result.status; +} + +function audit() { + console.log("Registered worktrees (unmanaged entries are report-only):"); + console.log(command("git", ["worktree", "list"], root)); + const origin = command("git", ["remote", "get-url", "origin"], root); + console.log("Independent sibling clones of this repository (report-only):"); + for (const entry of readdirSync(dirname(root), { withFileTypes: true })) { + const candidate = join(dirname(root), entry.name); + if (!entry.isDirectory() || candidate === root || !existsSync(join(candidate, ".git"))) { + continue; + } + try { + if ( + primaryRoot(candidate) !== candidate || + command("git", ["remote", "get-url", "origin"], candidate) !== origin + ) { + continue; + } + const modules = lstatSync(join(candidate, "node_modules"), { throwIfNoEntry: false }); + const kind = !modules + ? "no node_modules" + : modules.isSymbolicLink() + ? "node_modules link (not duplicate bytes)" + : "physical node_modules (size not inferred)"; + console.log(`${candidate}: ${kind}`); + } catch { + console.log(`KEEP: could not classify ${candidate}`); + } + } +} + +async function main() { + const [action, name, ...args] = process.argv.slice(2); + if (!action || action === "--help") { + console.log(`Usage: node scripts/platformclaw-dev-task.mjs + create --branch fix/name [--ref origin/main] + run -- [arguments...] + image -- + finish [--apply] Preview by default; retain dirty worktrees + recover --confirm-stopped + list Show task ownership and unresolved outcomes + sweep [--apply] Retry explicitly finished tasks only + audit Report legacy worktrees/clones; never delete them +Commands must be bounded and joined: do not launch detached background services. +Task images/temporary sources must use these entry points, not ad-hoc global paths.`); + return 0; + } + root = primaryRoot(process.cwd()); + databasePath = join(root, ".git", "platformclaw-dev-resources.sqlite"); + if (action === "audit") { + audit(); + return 0; + } + if (action === "list" || action === "sweep") { + if (!existsSync(databasePath)) { + console.log("No managed development tasks; legacy resources are not adopted automatically"); + return 0; + } + let failed = false; + for (const task of tasks()) { + console.log(`${task.name}\t${task.state}\t${paths(task).workspace}\t${task.note}`); + if (action === "sweep" && task.state === "finished") { + try { + await locked(`task:${task.name}`, async () => { + const current = taskByName(task.name); + if (current.state === "finished") { + try { + await cleanupTask(current, name === "--apply"); + } catch (error) { + if (name === "--apply") { + update(current, { note: error.message }); + } + throw error; + } + } + }); + } catch (error) { + console.error(`KEEP: ${task.name}: ${error.message}`); + failed = true; + } + } + } + return failed ? 1 : 0; + } + return await locked(`task:${name}`, async () => { + if (action === "create") { + await createTask(name, args); + return 0; + } + const task = taskByName(name); + if (action === "run") { + return await runTask(task, args); + } + if (action === "image") { + return buildImage(task, args); + } + if (action === "recover" && args.length === 1 && args[0] === "--confirm-stopped") { + if (!["running", "incomplete"].includes(task.state)) { + throw new Error("Only interrupted tasks require recovery"); + } + update(task, { + state: "active", + note: "Operator confirmed the original command and descendants stopped", + }); + console.log(`Recovered ${name}; inspect workspace, then run or finish explicitly`); + return 0; + } + if (action !== "finish" || args.some((arg) => arg !== "--apply")) { + throw new Error("Unknown command/arguments; use --help"); + } + if (["running", "incomplete"].includes(task.state)) { + throw new Error( + "Task outcome is uncertain; inspect original command/descendants, then recover --confirm-stopped", + ); + } + if (task.state === "cleaned") { + console.log(`Already cleaned: ${name}`); + return 0; + } + const apply = args.includes("--apply"); + if (apply) { + update(task, { state: "finished", note: "Completion approved; cleanup pending" }); + } + try { + await cleanupTask(task, apply); + } catch (error) { + if (apply) { + update(task, { note: error.message }); + } + throw error; + } + return 0; + }); +} + +try { + process.exitCode = await main(); +} catch (error) { + console.error(`[platformclaw-dev-task] ${error.message}`); + process.exitCode = 1; +} finally { + database?.close(); + if (process.exitCode) { + console.error(`[platformclaw-dev-task] FAILED (exit ${process.exitCode})`); + } +} diff --git a/test/scripts/platformclaw-check.test.ts b/test/scripts/platformclaw-check.test.ts index 0c3c523deef..71c84dccaeb 100644 --- a/test/scripts/platformclaw-check.test.ts +++ b/test/scripts/platformclaw-check.test.ts @@ -8,6 +8,16 @@ import { import { classifyPlatformClawChanges } from "../../scripts/platformclaw-ci-plan.mjs"; describe("PlatformClaw shared checks", () => { + it("runs developer lifecycle lint and behavior proof for resource tooling changes", () => { + const plan = classifyPlatformClawChanges(["scripts/platformclaw-dev-task.mjs"]); + const commands = createPlatformClawCheckCommands(surfacesForPlan(plan)); + expect( + commands.find((entry) => entry.label === "lint PlatformClaw CI planner")?.args, + ).toContain("scripts/platformclaw-dev-task.mjs"); + expect( + commands.find((entry) => entry.label === "test PlatformClaw CI planner")?.args, + ).toContain("test/scripts/platformclaw-dev-task.test.ts"); + }); it("runs lint, production and test types, and tests for Admin HTTP RPC", () => { const commands = createPlatformClawCheckCommands(["admin-http-rpc"]); diff --git a/test/scripts/platformclaw-dev-task.test.ts b/test/scripts/platformclaw-dev-task.test.ts new file mode 100644 index 00000000000..12527b657ed --- /dev/null +++ b/test/scripts/platformclaw-dev-task.test.ts @@ -0,0 +1,399 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + readdirSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; + +const script = fileURLToPath(new URL("../../scripts/platformclaw-dev-task.mjs", import.meta.url)); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function git(root: string, ...args: string[]) { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + return result.stdout.trim(); +} + +function createRepository() { + const root = realpathSync(tempDirs.make("platformclaw-dev-task-")); + git(root, "init", "--quiet", "--initial-branch=main"); + git(root, "config", "user.name", "PlatformClaw Test"); + git(root, "config", "user.email", "test@platformclaw.invalid"); + git(root, "config", "commit.gpgsign", "false"); + git(root, "config", "core.autocrlf", "false"); + git(root, "config", "core.hooksPath", path.join(root, ".git", "hooks")); + writeFileSync(path.join(root, "source.txt"), "original\n"); + writeFileSync(path.join(root, ".gitignore"), "/.worktrees/\n/.artifacts/\n/ignored/\n"); + git(root, "add", "source.txt", ".gitignore"); + git(root, "commit", "--quiet", "-m", "task fixture"); + return root; +} + +function cli(root: string, ...args: string[]) { + return spawnSync(process.execPath, [script, ...args], { cwd: root, encoding: "utf8" }); +} + +function expectSuccess(result: ReturnType) { + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); +} + +function createTask(root: string, name: string, primary = root) { + expectSuccess(cli(root, "create", name, "--branch", `fix/${name}`, "--ref", "HEAD")); + const matches = readdirSync(path.join(primary, ".worktrees")).filter((entry) => + entry.startsWith(`dev-${name}-`), + ); + expect(matches).toHaveLength(1); + const [entry] = matches; + if (!entry) { + throw new Error("Managed task did not create its worktree"); + } + return path.join(primary, ".worktrees", entry); +} + +const workspaceLink = "packages/a/node_modules/b"; +const workspaceLinkTarget = "../../b"; + +function materializeWorkspaceLink(root: string, target: string) { + const link = path.join(root, workspaceLink); + mkdirSync(path.dirname(link), { recursive: true }); + if (lstatSync(link, { throwIfNoEntry: false })) { + rmSync(link); + } + if (process.platform === "win32") { + writeFileSync(link, target); + } else { + symlinkSync(target, link, "dir"); + } + return link; +} + +function commitWorkspaceLink(root: string) { + git(root, "config", "core.symlinks", process.platform === "win32" ? "false" : "true"); + materializeWorkspaceLink(root, workspaceLinkTarget); + const blob = spawnSync("git", ["hash-object", "-w", "--stdin"], { + cwd: root, + encoding: "utf8", + input: workspaceLinkTarget, + }); + expect(blob.status, `${blob.stdout}\n${blob.stderr}`).toBe(0); + git( + root, + "update-index", + "--add", + "--cacheinfo", + `120000,${blob.stdout.trim()},${workspaceLink}`, + ); + git(root, "commit", "--quiet", "-m", "tracked workspace link fixture"); +} + +function observeChild(child: ChildProcess) { + let output = ""; + const ready = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", () => reject(new Error(`Task exited before readiness: ${output}`))); + child.stdout?.on("data", (data: Buffer) => { + output += data.toString(); + const match = /DEV_TASK_CHILD=(\d+)\r?\n/u.exec(output); + if (match) { + resolve(Number(match[1])); + } + }); + }); + child.stderr?.on("data", (data: Buffer) => { + output += data.toString(); + }); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", () => resolve()); + }); + return { ready, exited }; +} + +function stopOwnedProcess(pid: number) { + try { + process.kill(pid, "SIGKILL"); + } catch (error) { + if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") { + throw error; + } + } +} + +describe("PlatformClaw developer task lifecycle", () => { + it("bootstraps create, run, and finish with Node alone before any package installation", () => { + const root = createRepository(); + const bootstrap = path.join(root, ".artifacts", "bootstrap", "platformclaw-dev-task.mjs"); + mkdirSync(path.dirname(bootstrap), { recursive: true }); + copyFileSync(script, bootstrap); + const runBootstrap = (...args: string[]) => + spawnSync(process.execPath, [bootstrap, ...args], { cwd: root, encoding: "utf8" }); + + expectSuccess( + runBootstrap("create", "bootstrap", "--branch", "fix/bootstrap", "--ref", "HEAD"), + ); + expectSuccess( + runBootstrap("run", "bootstrap", "--", process.execPath, "-e", "process.exit(0)"), + ); + expectSuccess(runBootstrap("finish", "bootstrap", "--apply")); + + expect(existsSync(path.join(root, "node_modules"))).toBe(false); + expect(readdirSync(path.join(root, ".worktrees"))).toEqual([]); + expect(git(root, "branch", "--show-current")).toBe("main"); + }); + + it("shares the canonical registry from linked worktrees without changing the primary branch", () => { + const root = createRepository(); + const first = createTask(root, "first"); + const second = createTask(first, "second", root); + + expect(path.dirname(second)).toBe(path.join(root, ".worktrees")); + expect(git(root, "branch", "--show-current")).toBe("main"); + expect(git(first, "branch", "--show-current")).toBe("fix/first"); + const listing = cli(first, "list"); + expectSuccess(listing); + expect(listing.stdout).toContain("first"); + expect(listing.stdout).toContain("second"); + expect(existsSync(path.join(first, ".git", "platformclaw-dev-resources.sqlite"))).toBe(false); + expect(existsSync(path.join(root, ".git", "platformclaw-dev-resources.sqlite"))).toBe(true); + }); + + it.each([0, 23])( + "cleans joined command scratch on exit %s while preserving the worktree", + (exit) => { + const root = createRepository(); + const workspace = createTask(root, "command"); + const result = cli( + root, + "run", + "command", + "--", + process.execPath, + "-e", + [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const temporary = path.join(process.env.TMPDIR, "disposable.txt");', + 'fs.writeFileSync(temporary, "temporary proof source");', + 'console.log("DEV_TASK_OBSERVED=" + JSON.stringify({ cwd: process.cwd(), temporary,', + "tmp: process.env.TMP, temp: process.env.TEMP, tmpdir: process.env.TMPDIR }));", + `process.exit(${exit});`, + ].join("\n"), + ); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(exit); + const match = /DEV_TASK_OBSERVED=(\{[^\r\n]+\})/u.exec(result.stdout); + if (!match?.[1]) { + throw new Error("Managed command did not report its scratch path"); + } + const observed = JSON.parse(match[1]) as { + cwd: string; + temporary: string; + tmp: string; + temp: string; + tmpdir: string; + }; + expect(realpathSync(observed.cwd)).toBe(realpathSync(workspace)); + expect(observed.tmp).toBe(observed.tmpdir); + expect(observed.temp).toBe(observed.tmpdir); + expect( + path.relative(path.join(root, ".artifacts", "dev-tasks"), observed.tmpdir), + ).not.toMatch(/^\.\./u); + expect(existsSync(observed.temporary)).toBe(false); + expect(readFileSync(path.join(workspace, "source.txt"), "utf8")).toBe("original\n"); + }, + ); + + it("previews completion without enrolling the task into automatic deletion", () => { + const root = createRepository(); + const workspace = createTask(root, "preview"); + + expectSuccess(cli(root, "finish", "preview")); + expectSuccess(cli(root, "sweep", "--apply")); + + expect(existsSync(workspace)).toBe(true); + expectSuccess(cli(root, "finish", "preview", "--apply")); + expect(existsSync(workspace)).toBe(false); + expect(git(root, "rev-parse", "fix/preview")).toBe(git(root, "rev-parse", "main")); + }); + + it.each(["tracked", "untracked", "ignored"] as const)( + "preserves %s files and retries explicitly finished work only after the files are resolved", + (kind) => { + const root = createRepository(); + const workspace = createTask(root, "preserve"); + const relative = + kind === "tracked" ? "source.txt" : kind === "ignored" ? "ignored/proof.txt" : "new.txt"; + const saved = path.join(workspace, relative); + mkdirSync(path.dirname(saved), { recursive: true }); + writeFileSync(saved, "keep this developer work\n"); + + expect(cli(root, "finish", "preserve", "--apply").status).not.toBe(0); + cli(root, "sweep", "--apply"); + expect(readFileSync(saved, "utf8")).toBe("keep this developer work\n"); + + if (kind === "tracked") { + writeFileSync(saved, "original\n"); + } else { + rmSync(kind === "ignored" ? path.dirname(saved) : saved, { recursive: true }); + } + expectSuccess(cli(root, "sweep", "--apply")); + expect(existsSync(workspace)).toBe(false); + expect(git(root, "branch", "--show-current")).toBe("main"); + }, + ); + + it.each(["--assume-unchanged", "--skip-worktree"] as const)( + "preserves modified tracked content hidden by %s", + (flag) => { + const root = createRepository(); + const workspace = createTask(root, "hidden"); + const source = path.join(workspace, "source.txt"); + git(workspace, "update-index", flag, "source.txt"); + writeFileSync(source, "unique developer work hidden from git status\n"); + expect(git(workspace, "status", "--porcelain")).toBe(""); + + expect(cli(root, "finish", "hidden", "--apply").status).not.toBe(0); + expect(readFileSync(source, "utf8")).toBe("unique developer work hidden from git status\n"); + + git(workspace, "update-index", flag.replace("--", "--no-"), "source.txt"); + writeFileSync(source, "original\n"); + expectSuccess(cli(root, "sweep", "--apply")); + expect(existsSync(workspace)).toBe(false); + }, + ); + + it.each([false, true])( + "compares canonical guarded workspace links to their tracked target (modified=%s)", + (modified) => { + const root = createRepository(); + commitWorkspaceLink(root); + const workspace = createTask(root, "guarded"); + git(workspace, "update-index", "--skip-worktree", workspaceLink); + const expectedTarget = modified ? "../../unique-developer-target" : workspaceLinkTarget; + const link = materializeWorkspaceLink(workspace, expectedTarget); + expect(git(workspace, "status", "--porcelain")).toBe(""); + + const finished = cli(root, "finish", "guarded", "--apply"); + if (!modified) { + expectSuccess(finished); + expect(existsSync(workspace)).toBe(false); + return; + } + expect(finished.status).not.toBe(0); + expect(process.platform === "win32" ? readFileSync(link, "utf8") : readlinkSync(link)).toBe( + expectedTarget, + ); + git(workspace, "update-index", "--no-skip-worktree", workspaceLink); + materializeWorkspaceLink(workspace, workspaceLinkTarget); + expectSuccess(cli(root, "sweep", "--apply")); + expect(existsSync(workspace)).toBe(false); + }, + ); + + it("refuses finish during a run and requires explicit recovery after controller loss", async () => { + const root = createRepository(); + const workspace = createTask(root, "interrupted"); + const controller = spawn( + process.execPath, + [ + script, + "run", + "interrupted", + "--", + process.execPath, + "-e", + 'console.log("DEV_TASK_CHILD=" + process.pid); setInterval(() => {}, 1000);', + ], + { cwd: root, stdio: ["ignore", "pipe", "pipe"] }, + ); + const observed = observeChild(controller); + let commandPid: number | undefined; + try { + commandPid = await observed.ready; + expect(cli(root, "finish", "interrupted", "--apply").status).not.toBe(0); + stopOwnedProcess(controller.pid!); + await observed.exited; + stopOwnedProcess(commandPid); + commandPid = undefined; + + cli(root, "sweep", "--apply"); + expect(existsSync(workspace)).toBe(true); + expect(cli(root, "finish", "interrupted", "--apply").status).not.toBe(0); + expect(cli(root, "recover", "interrupted").status).not.toBe(0); + expectSuccess(cli(root, "recover", "interrupted", "--confirm-stopped")); + expectSuccess(cli(root, "finish", "interrupted", "--apply")); + expect(existsSync(workspace)).toBe(false); + } finally { + if (commandPid !== undefined) { + stopOwnedProcess(commandPid); + } + if ( + controller.pid !== undefined && + controller.exitCode === null && + controller.signalCode === null + ) { + stopOwnedProcess(controller.pid); + } + await observed.exited; + } + }); + + it("rejects traversal and does not follow a task scratch directory link", () => { + const root = createRepository(); + expect(cli(root, "create", "../escape", "--branch", "fix/escape").status).not.toBe(0); + const workspace = createTask(root, "linked"); + const taskRoot = path.join(root, ".artifacts", "dev-tasks"); + const folders = readdirSync(taskRoot); + expect(folders).toHaveLength(1); + const [folder] = folders; + if (!folder) { + throw new Error("Managed task did not create its scratch directory"); + } + const scratch = path.join(taskRoot, folder); + const outside = realpathSync(tempDirs.make("platformclaw-dev-task-outside-")); + writeFileSync(path.join(outside, "important.txt"), "not task-owned\n"); + rmSync(scratch, { recursive: true, force: true }); + symlinkSync(outside, scratch, process.platform === "win32" ? "junction" : "dir"); + try { + expect(cli(root, "finish", "linked", "--apply").status).not.toBe(0); + expect(readFileSync(path.join(outside, "important.txt"), "utf8")).toBe("not task-owned\n"); + expect(existsSync(workspace)).toBe(true); + } finally { + // Remove only this test's link before the shared temp tracker tears down its root. + rmSync(scratch, { recursive: true, force: true }); + } + }); + + it.each(["-t", "-qt", "-qo", "-qotag", "--tag=platformclaw:latest", "--output=type=registry"])( + "rejects unmanaged image publication through %s before invoking Docker", + (flag) => { + const root = createRepository(); + createTask(root, "image-options"); + const result = cli( + root, + "image", + "image-options", + "proof", + "--", + flag, + "platformclaw:latest", + ".", + ); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("Managed image builds own their tags and local output"); + expectSuccess(cli(root, "finish", "image-options", "--apply")); + }, + ); +}); diff --git a/test/scripts/platformclaw-runtime-docker.test.ts b/test/scripts/platformclaw-runtime-docker.test.ts index 39ed0408d47..9730cbafaed 100644 --- a/test/scripts/platformclaw-runtime-docker.test.ts +++ b/test/scripts/platformclaw-runtime-docker.test.ts @@ -505,7 +505,8 @@ describe("PlatformClaw Docker runtime", () => { cleanup.indexOf("roll back abandoned release publication"), ); expect(cleanup).not.toContain('docker(["image", "rm", "--force"'); - expect(maintenance).toContain("New-ScheduledTaskTrigger -Weekly"); + expect(maintenance).toContain("New-ScheduledTaskTrigger -Daily"); + expect(maintenance).toContain("-RepetitionInterval (New-TimeSpan -Hours 4)"); expect(compact).toContain("Optimize-VHD -Path $resolved -Mode Full"); expect(compact).toContain("$expectedPrefix"); expect(compact).toContain("Assert-NoReparsePoint $resolved");