diff --git a/.github/labeler.yml b/.github/labeler.yml index d6f5879d8be..9e9af7240a8 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -195,7 +195,6 @@ - changed-files: - any-glob-to-any-file: - "docs/**" - - "docs.acp.md" "cli": - changed-files: @@ -222,6 +221,7 @@ - "setup-podman.sh" - ".dockerignore" - "scripts/docker/setup.sh" + - "scripts/docker/sandbox/Dockerfile*" - "scripts/podman/setup.sh" - "scripts/**/*docker*" - "scripts/**/Dockerfile*" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 2b492cac80a..360cadceb9c 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -92,7 +92,7 @@ jobs: const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); const totalChangedLines = files.reduce((total, file) => { const path = file.filename ?? ""; - if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { + if (path.startsWith("docs/") || excludedLockfiles.has(path)) { return total; } return total + (file.additions ?? 0) + (file.deletions ?? 0); @@ -606,7 +606,7 @@ jobs: const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]); const totalChangedLines = files.reduce((total, file) => { const path = file.filename ?? ""; - if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) { + if (path.startsWith("docs/") || excludedLockfiles.has(path)) { return total; } return total + (file.additions ?? 0) + (file.deletions ?? 0); diff --git a/.github/workflows/sandbox-common-smoke.yml b/.github/workflows/sandbox-common-smoke.yml index 54ff92751eb..366b2688ca2 100644 --- a/.github/workflows/sandbox-common-smoke.yml +++ b/.github/workflows/sandbox-common-smoke.yml @@ -4,14 +4,14 @@ on: push: branches: [main] paths: - - Dockerfile.sandbox - - Dockerfile.sandbox-common + - scripts/docker/sandbox/Dockerfile + - scripts/docker/sandbox/Dockerfile.common - scripts/sandbox-common-setup.sh pull_request: types: [opened, reopened, synchronize, ready_for_review, converted_to_draft] paths: - - Dockerfile.sandbox - - Dockerfile.sandbox-common + - scripts/docker/sandbox/Dockerfile + - scripts/docker/sandbox/Dockerfile.common - scripts/sandbox-common-setup.sh permissions: diff --git a/.gitignore b/.gitignore index a882138473d..9047b834445 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ coverage __openclaw_vitest__/ __pycache__/ *.pyc -.tsbuildinfo +*.tsbuildinfo .pnpm-store .worktrees/ .DS_Store @@ -93,7 +93,7 @@ docs/internal/ tmp/ IDENTITY.md USER.md -.tgz +*.tgz .idea # local tooling @@ -187,6 +187,10 @@ changelog/fragments/ .tmp/ .vmux* .artifacts/ +.openclaw-config-doc-cache/ +openclaw-path-alias-*/ +/.pi/ +/C:\\openclaw/ test/fixtures/openclaw-vitest-unit-report.json analysis/ .artifacts/qa-e2e/ diff --git a/docs.acp.md b/docs.acp.md deleted file mode 100644 index 1e93ee0cf63..00000000000 --- a/docs.acp.md +++ /dev/null @@ -1,244 +0,0 @@ -# OpenClaw ACP Bridge - -This document describes how the OpenClaw ACP (Agent Client Protocol) bridge works, -how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it. - -## Overview - -`openclaw acp` exposes an ACP agent over stdio and forwards prompts to a running -OpenClaw Gateway over WebSocket. It keeps ACP session ids mapped to Gateway -session keys so IDEs can reconnect to the same agent transcript or reset it on -request. - -Key goals: - -- Minimal ACP surface area (stdio, NDJSON). -- Stable session mapping across reconnects. -- Works with existing Gateway session store (list/resolve/reset). -- Safe defaults (isolated ACP session keys by default). - -## Bridge Scope - -`openclaw acp` is a Gateway-backed ACP bridge, not a full ACP-native editor -runtime. It is designed to route IDE prompts into an existing OpenClaw Gateway -session with predictable session mapping and basic streaming updates. - -## Compatibility Matrix - -| ACP area | Status | Notes | -| --------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `initialize`, `newSession`, `prompt`, `cancel` | Implemented | Core bridge flow over stdio to Gateway chat/send + abort. | -| `listSessions`, slash commands | Implemented | Session list works against Gateway session state; commands are advertised via `available_commands_update`. | -| `loadSession` | Partial | Rebinds the ACP session to a Gateway session key and replays stored user/assistant text history. Tool/system history is not reconstructed yet. | -| Prompt content (`text`, embedded `resource`, images) | Partial | Text/resources are flattened into chat input; images become Gateway attachments. | -| Session modes | Partial | `session/set_mode` is supported and the bridge exposes initial Gateway-backed session controls for thought level, tool verbosity, reasoning, usage detail, and elevated actions. Broader ACP-native mode/config surfaces are still out of scope. | -| Session info and usage updates | Partial | The bridge emits `session_info_update` and best-effort `usage_update` notifications from cached Gateway session snapshots. Usage is approximate and only sent when Gateway token totals are marked fresh. | -| Tool streaming | Partial | `tool_call` / `tool_call_update` events include raw I/O, text content, and best-effort file locations when Gateway tool args/results expose them. Embedded terminals and richer diff-native output are still not exposed. | -| Per-session MCP servers (`mcpServers`) | Unsupported | Bridge mode rejects per-session MCP server requests. Configure MCP on the OpenClaw gateway or agent instead. | -| Client filesystem methods (`fs/read_text_file`, `fs/write_text_file`) | Unsupported | The bridge does not call ACP client filesystem methods. | -| Client terminal methods (`terminal/*`) | Unsupported | The bridge does not create ACP client terminals or stream terminal ids through tool calls. | -| Session plans / thought streaming | Unsupported | The bridge currently emits output text and tool status, not ACP plan or thought updates. | - -## Known Limitations - -- `loadSession` replays stored user and assistant text history, but it does not - reconstruct historic tool calls, system notices, or richer ACP-native event - types. -- If multiple ACP clients share the same Gateway session key, event and cancel - routing are best-effort rather than strictly isolated per client. Prefer the - default isolated `acp:` sessions when you need clean editor-local - turns. -- Gateway stop states are translated into ACP stop reasons, but that mapping is - less expressive than a fully ACP-native runtime. -- Initial session controls currently surface a focused subset of Gateway knobs: - thought level, tool verbosity, reasoning, usage detail, and elevated - actions. Model selection and exec-host controls are not yet exposed as ACP - config options. -- `session_info_update` and `usage_update` are derived from Gateway session - snapshots, not live ACP-native runtime accounting. Usage is approximate, - carries no cost data, and is only emitted when the Gateway marks total token - data as fresh. -- Tool follow-along data is best-effort. The bridge can surface file paths that - appear in known tool args/results, but it does not yet emit ACP terminals or - structured file diffs. - -## How can I use this - -Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to -drive a OpenClaw Gateway session. - -Quick steps: - -1. Run a Gateway (local or remote). -2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags. -3. Point the IDE to run `openclaw acp` over stdio. - -Example config: - -```bash -openclaw config set gateway.remote.url wss://gateway-host:18789 -openclaw config set gateway.remote.token -``` - -Example run: - -```bash -openclaw acp --url wss://gateway-host:18789 --token -``` - -## Selecting agents - -ACP does not pick agents directly. It routes by the Gateway session key. - -Use agent-scoped session keys to target a specific agent: - -```bash -openclaw acp --session agent:main:main -openclaw acp --session agent:design:main -openclaw acp --session agent:qa:bug-123 -``` - -Each ACP session maps to a single Gateway session key. One agent can have many -sessions; ACP defaults to an isolated `acp:` session unless you override -the key or label. - -## Zed editor setup - -Add a custom ACP agent in `~/.config/zed/settings.json`: - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": ["acp"], - "env": {} - } - } -} -``` - -To target a specific Gateway or agent: - -```json -{ - "agent_servers": { - "OpenClaw ACP": { - "type": "custom", - "command": "openclaw", - "args": [ - "acp", - "--url", - "wss://gateway-host:18789", - "--token", - "", - "--session", - "agent:design:main" - ], - "env": {} - } - } -} -``` - -In Zed, open the Agent panel and select “OpenClaw ACP” to start a thread. - -## Execution Model - -- ACP client spawns `openclaw acp` and speaks ACP messages over stdio. -- The bridge connects to the Gateway using existing auth config (or CLI flags). -- ACP `prompt` translates to Gateway `chat.send`. -- Gateway streaming events are translated back into ACP streaming events. -- ACP `cancel` maps to Gateway `chat.abort` for the active run. - -## Session Mapping - -By default each ACP session is mapped to a dedicated Gateway session key: - -- `acp:` unless overridden. - -You can override or reuse sessions in two ways: - -1. CLI defaults - -```bash -openclaw acp --session agent:main:main -openclaw acp --session-label "support inbox" -openclaw acp --reset-session -``` - -2. ACP metadata per session - -```json -{ - "_meta": { - "sessionKey": "agent:main:main", - "sessionLabel": "support inbox", - "resetSession": true, - "requireExisting": false - } -} -``` - -Rules: - -- `sessionKey`: direct Gateway session key. -- `sessionLabel`: resolve an existing session by label. -- `resetSession`: mint a new transcript for the key before first use. -- `requireExisting`: fail if the key/label does not exist. - -### Session Listing - -ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered -summary suitable for IDE session pickers. `_meta.limit` can cap the number of -sessions returned. - -## Prompt Translation - -ACP prompt inputs are converted into a Gateway `chat.send`: - -- `text` and `resource` blocks become prompt text. -- `resource_link` with image mime types become attachments. -- The working directory can be prefixed into the prompt (default on, can be - disabled with `--no-prefix-cwd`). - -Gateway streaming events are translated into ACP `message` and `tool_call` -updates. Terminal Gateway states map to ACP `done` with stop reasons: - -- `complete` -> `stop` -- `aborted` -> `cancel` -- `error` -> `error` - -## Auth + Gateway Discovery - -`openclaw acp` resolves the Gateway URL and auth from CLI flags or config: - -- `--url` / `--token` / `--password` take precedence. -- Otherwise use configured `gateway.remote.*` settings. - -## Operational Notes - -- ACP sessions are stored in memory for the bridge process lifetime. -- Gateway session state is persisted by the Gateway itself. -- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout). -- ACP runs can be canceled and the active run id is tracked per session. - -## Compatibility - -- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.15.x). -- Works with ACP clients that implement `initialize`, `newSession`, - `loadSession`, `prompt`, `cancel`, and `listSessions`. -- Bridge mode rejects per-session `mcpServers` instead of silently ignoring - them. Configure MCP at the Gateway or agent layer. - -## Testing - -- Unit: `src/acp/session.test.ts` covers run id lifecycle. -- Full gate: `pnpm build && pnpm check && pnpm test && pnpm docs:build`. - -## Related Docs - -- CLI usage: `docs/cli/acp.md` -- Session model: `docs/concepts/session.md` -- Session management internals: `docs/reference/session-management-compaction.md` diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 39ad7a2f3e3..73bcaa180bf 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -409,7 +409,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker scripts/sandbox-common-setup.sh ``` - From an npm install, build the default image first (see above), then build the common image on top using the [`Dockerfile.sandbox-common`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-common) from the repository. + From an npm install, build the default image first (see above), then build the common image on top using the [`scripts/docker/sandbox/Dockerfile.common`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.common) from the repository. Then set `agents.defaults.sandbox.docker.image` to `openclaw-sandbox-common:bookworm-slim`. @@ -421,7 +421,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker scripts/sandbox-browser-setup.sh ``` - From an npm install, build using the [`Dockerfile.sandbox-browser`](https://github.com/openclaw/openclaw/blob/main/Dockerfile.sandbox-browser) from the repository. + From an npm install, build using the [`scripts/docker/sandbox/Dockerfile.browser`](https://github.com/openclaw/openclaw/blob/main/scripts/docker/sandbox/Dockerfile.browser) from the repository. diff --git a/scripts/changed-lanes.mjs b/scripts/changed-lanes.mjs index b1cacc1f75b..5faf6dcbf64 100644 --- a/scripts/changed-lanes.mjs +++ b/scripts/changed-lanes.mjs @@ -9,7 +9,7 @@ const APP_PATH_RE = /^(?:apps\/|Swabble\/|appcast\.xml$)/u; const EXTENSION_PATH_RE = /^extensions\/[^/]+(?:\/|$)/u; const CORE_PATH_RE = /^(?:src\/|ui\/|packages\/)/u; const TOOLING_PATH_RE = - /^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.gitignore$|\.oxlint.*|\.oxfmt.*)/u; + /^(?:scripts\/|test\/vitest\/|\.github\/|git-hooks\/|Dockerfile\.sandbox(?:-(?:browser|common))?$|vitest(?:\..+)?\.config\.ts$|tsconfig.*\.json$|\.gitignore$|\.oxlint.*|\.oxfmt.*)/u; const ROOT_GLOBAL_PATH_RE = /^(?:package\.json$|pnpm-lock\.yaml$|pnpm-workspace\.yaml$|tsdown\.config\.ts$|vitest\.config\.ts$)/u; const LIVE_DOCKER_TOOLING_PATH_RE = diff --git a/Dockerfile.sandbox b/scripts/docker/sandbox/Dockerfile similarity index 100% rename from Dockerfile.sandbox rename to scripts/docker/sandbox/Dockerfile diff --git a/Dockerfile.sandbox-browser b/scripts/docker/sandbox/Dockerfile.browser similarity index 100% rename from Dockerfile.sandbox-browser rename to scripts/docker/sandbox/Dockerfile.browser diff --git a/Dockerfile.sandbox-common b/scripts/docker/sandbox/Dockerfile.common similarity index 100% rename from Dockerfile.sandbox-common rename to scripts/docker/sandbox/Dockerfile.common diff --git a/scripts/docker/setup.sh b/scripts/docker/setup.sh index 76ee72f1d3b..881505677ea 100755 --- a/scripts/docker/setup.sh +++ b/scripts/docker/setup.sh @@ -576,15 +576,15 @@ if [[ -n "$SANDBOX_ENABLED" ]]; then echo "" echo "==> Sandbox setup" - # Build sandbox image if Dockerfile.sandbox exists. - if [[ -f "$ROOT_DIR/Dockerfile.sandbox" ]]; then + sandbox_dockerfile="$ROOT_DIR/scripts/docker/sandbox/Dockerfile" + if [[ -f "$sandbox_dockerfile" ]]; then echo "Building sandbox image: openclaw-sandbox:bookworm-slim" run_docker_build \ -t "openclaw-sandbox:bookworm-slim" \ - -f "$ROOT_DIR/Dockerfile.sandbox" \ + -f "$sandbox_dockerfile" \ "$ROOT_DIR" else - echo "WARNING: Dockerfile.sandbox not found in $ROOT_DIR" >&2 + echo "WARNING: sandbox Dockerfile not found at $sandbox_dockerfile" >&2 echo " Sandbox config will be applied but no sandbox image will be built." >&2 echo " Agent exec may fail if the configured sandbox image does not exist." >&2 fi diff --git a/scripts/sandbox-browser-setup.sh b/scripts/sandbox-browser-setup.sh index bec750cf9e8..2f20c3a62b5 100755 --- a/scripts/sandbox-browser-setup.sh +++ b/scripts/sandbox-browser-setup.sh @@ -6,5 +6,5 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh" IMAGE_NAME="openclaw-sandbox-browser:bookworm-slim" -docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox-browser" "$ROOT_DIR" +docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.browser" "$ROOT_DIR" echo "Built ${IMAGE_NAME}" diff --git a/scripts/sandbox-common-setup.sh b/scripts/sandbox-common-setup.sh index 4d1dff1d983..7d0655ba680 100755 --- a/scripts/sandbox-common-setup.sh +++ b/scripts/sandbox-common-setup.sh @@ -27,7 +27,7 @@ echo "Building ${TARGET_IMAGE} with: ${PACKAGES}" docker_build_exec \ -t "${TARGET_IMAGE}" \ - -f "$ROOT_DIR/Dockerfile.sandbox-common" \ + -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.common" \ --build-arg BASE_IMAGE="${BASE_IMAGE}" \ --build-arg PACKAGES="${PACKAGES}" \ --build-arg INSTALL_PNPM="${INSTALL_PNPM}" \ diff --git a/scripts/sandbox-setup.sh b/scripts/sandbox-setup.sh index 567c7de5965..46de6862a6d 100755 --- a/scripts/sandbox-setup.sh +++ b/scripts/sandbox-setup.sh @@ -6,5 +6,5 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh" IMAGE_NAME="openclaw-sandbox:bookworm-slim" -docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/Dockerfile.sandbox" "$ROOT_DIR" +docker_build_exec -t "${IMAGE_NAME}" -f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile" "$ROOT_DIR" echo "Built ${IMAGE_NAME}" diff --git a/src/docker-build-cache.test.ts b/src/docker-build-cache.test.ts index 738549d8df1..4b6f9c7bdb4 100644 --- a/src/docker-build-cache.test.ts +++ b/src/docker-build-cache.test.ts @@ -6,9 +6,9 @@ import { beforeAll, describe, expect, it } from "vitest"; const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const dockerfilePaths = [ "Dockerfile", - "Dockerfile.sandbox", - "Dockerfile.sandbox-browser", - "Dockerfile.sandbox-common", + "scripts/docker/sandbox/Dockerfile", + "scripts/docker/sandbox/Dockerfile.browser", + "scripts/docker/sandbox/Dockerfile.common", "scripts/docker/cleanup-smoke/Dockerfile", "scripts/docker/install-sh-smoke/Dockerfile", "scripts/docker/install-sh-e2e/Dockerfile", @@ -85,7 +85,7 @@ describe("docker build cache layout", () => { }); it("does not leave empty shell continuation lines in sandbox-common", async () => { - const dockerfile = await readRepoFile("Dockerfile.sandbox-common"); + const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common"); expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\"); expect(dockerfile).toContain( 'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi', diff --git a/src/docker-image-digests.test.ts b/src/docker-image-digests.test.ts index cf48dfbc8ff..ecd3fcec4d8 100644 --- a/src/docker-image-digests.test.ts +++ b/src/docker-image-digests.test.ts @@ -8,8 +8,8 @@ const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), ".."); const DIGEST_PINNED_DOCKERFILES = [ "Dockerfile", - "Dockerfile.sandbox", - "Dockerfile.sandbox-browser", + "scripts/docker/sandbox/Dockerfile", + "scripts/docker/sandbox/Dockerfile.browser", "scripts/docker/cleanup-smoke/Dockerfile", "scripts/docker/install-sh-e2e/Dockerfile", "scripts/docker/install-sh-nonroot/Dockerfile", diff --git a/src/docker-setup.e2e.test.ts b/src/docker-setup.e2e.test.ts index f5ff1305543..78293f65f5f 100644 --- a/src/docker-setup.e2e.test.ts +++ b/src/docker-setup.e2e.test.ts @@ -281,7 +281,11 @@ describe("scripts/docker/setup.sh", () => { it("forces BuildKit for local and sandbox docker builds", async () => { const activeSandbox = requireSandbox(sandbox); - await writeFile(join(activeSandbox.rootDir, "Dockerfile.sandbox"), "FROM scratch\n"); + await mkdir(join(activeSandbox.rootDir, "scripts", "docker", "sandbox"), { recursive: true }); + await writeFile( + join(activeSandbox.rootDir, "scripts", "docker", "sandbox", "Dockerfile"), + "FROM scratch\n", + ); await resetDockerLog(activeSandbox); const result = runDockerSetup(activeSandbox, { diff --git a/src/test-helpers/temp-dir.test.ts b/src/test-helpers/temp-dir.test.ts new file mode 100644 index 00000000000..31ca4823842 --- /dev/null +++ b/src/test-helpers/temp-dir.test.ts @@ -0,0 +1,73 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { withTempDir, withTempDirSync } from "./temp-dir.js"; + +const parentRoots: string[] = []; + +async function makeParentRoot(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-temp-dir-helper-test-")); + parentRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all( + parentRoots.splice(0).map((root) => + fs.rm(root, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }), + ), + ); +}); + +describe("withTempDir", () => { + it("removes the cached async prefix root when the case finishes", async () => { + const parentDir = await makeParentRoot(); + + await withTempDir({ prefix: "openclaw-leak-check-", parentDir }, async (dir) => { + await fs.writeFile(path.join(dir, "marker.txt"), "ok"); + }); + + await expect(fs.readdir(parentDir)).resolves.toEqual([]); + }); + + it("keeps the cached async prefix root while another case is active", async () => { + const parentDir = await makeParentRoot(); + let releaseFirst: (() => void) | undefined; + const firstCanFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const first = withTempDir({ prefix: "openclaw-shared-root-", parentDir }, async (dir) => { + await fs.writeFile(path.join(dir, "first.txt"), "ok"); + await firstCanFinish; + }); + + await withTempDir({ prefix: "openclaw-shared-root-", parentDir }, async (dir) => { + await fs.writeFile(path.join(dir, "second.txt"), "ok"); + await expect(fs.readdir(parentDir)).resolves.toHaveLength(1); + }); + + expect(releaseFirst).toBeDefined(); + releaseFirst?.(); + await first; + + await expect(fs.readdir(parentDir)).resolves.toEqual([]); + }); + + it("removes the cached sync prefix root when the case finishes", async () => { + const parentDir = await makeParentRoot(); + + withTempDirSync({ prefix: "openclaw-leak-check-sync-", parentDir }, (dir) => { + fsSync.writeFileSync(path.join(dir, "marker.txt"), "ok"); + }); + + await expect(fs.readdir(parentDir)).resolves.toEqual([]); + }); +}); diff --git a/src/test-helpers/temp-dir.ts b/src/test-helpers/temp-dir.ts index 896b2928975..30d77c9e081 100644 --- a/src/test-helpers/temp-dir.ts +++ b/src/test-helpers/temp-dir.ts @@ -3,9 +3,14 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -const asyncPrefixRoots = new Map(); -const pendingAsyncPrefixRoots = new Map>(); -const syncPrefixRoots = new Map(); +type PrefixRootState = { + path: string; + activeCount: number; +}; + +const asyncPrefixRoots = new Map(); +const pendingAsyncPrefixRoots = new Map>(); +const syncPrefixRoots = new Map(); let nextAsyncDirIndex = 0; let nextSyncDirIndex = 0; @@ -13,39 +18,88 @@ function getRootKey(options: { prefix: string; parentDir?: string }): string { return `${options.parentDir ?? os.tmpdir()}\u0000${options.prefix}`; } -async function ensureAsyncPrefixRoot(options: { +async function acquireAsyncPrefixRoot(options: { prefix: string; parentDir?: string; -}): Promise { +}): Promise { const key = getRootKey(options); const cached = asyncPrefixRoots.get(key); if (cached) { + cached.activeCount += 1; return cached; } const pending = pendingAsyncPrefixRoots.get(key); if (pending) { - return await pending; + const state = await pending; + state.activeCount += 1; + return state; } - const create = fs.mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix)); + const create = fs + .mkdtemp(path.join(options.parentDir ?? os.tmpdir(), options.prefix)) + .then((root) => ({ path: root, activeCount: 0 })); pendingAsyncPrefixRoots.set(key, create); try { - const root = await create; - asyncPrefixRoots.set(key, root); - return root; + const state = await create; + asyncPrefixRoots.set(key, state); + state.activeCount += 1; + return state; } finally { pendingAsyncPrefixRoots.delete(key); } } -function ensureSyncPrefixRoot(options: { prefix: string; parentDir?: string }): string { +function acquireSyncPrefixRoot(options: { prefix: string; parentDir?: string }): PrefixRootState { const key = getRootKey(options); const cached = syncPrefixRoots.get(key); if (cached) { + cached.activeCount += 1; return cached; } const root = fsSync.mkdtempSync(path.join(options.parentDir ?? os.tmpdir(), options.prefix)); - syncPrefixRoots.set(key, root); - return root; + const state = { path: root, activeCount: 1 }; + syncPrefixRoots.set(key, state); + return state; +} + +async function releaseAsyncPrefixRoot(options: { + prefix: string; + parentDir?: string; +}): Promise { + const key = getRootKey(options); + const state = asyncPrefixRoots.get(key); + if (!state) { + return; + } + state.activeCount -= 1; + if (state.activeCount > 0) { + return; + } + asyncPrefixRoots.delete(key); + await fs.rm(state.path, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); +} + +function releaseSyncPrefixRoot(options: { prefix: string; parentDir?: string }) { + const key = getRootKey(options); + const state = syncPrefixRoots.get(key); + if (!state) { + return; + } + state.activeCount -= 1; + if (state.activeCount > 0) { + return; + } + syncPrefixRoots.delete(key); + fsSync.rmSync(state.path, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); } export async function withTempDir( @@ -56,15 +110,15 @@ export async function withTempDir( }, run: (dir: string) => Promise, ): Promise { - const root = await ensureAsyncPrefixRoot(options); - const base = path.join(root, `dir-${String(nextAsyncDirIndex)}`); + const root = await acquireAsyncPrefixRoot(options); + const base = path.join(root.path, `dir-${String(nextAsyncDirIndex)}`); nextAsyncDirIndex += 1; - await fs.mkdir(base, { recursive: true }); - const dir = options.subdir ? path.join(base, options.subdir) : base; - if (options.subdir) { - await fs.mkdir(dir, { recursive: true }); - } try { + await fs.mkdir(base, { recursive: true }); + const dir = options.subdir ? path.join(base, options.subdir) : base; + if (options.subdir) { + await fs.mkdir(dir, { recursive: true }); + } return await run(dir); } finally { await fs.rm(base, { @@ -73,6 +127,7 @@ export async function withTempDir( maxRetries: 20, retryDelay: 25, }); + await releaseAsyncPrefixRoot(options); } } @@ -116,15 +171,15 @@ export function withTempDirSync( }, run: (dir: string) => T, ): T { - const root = ensureSyncPrefixRoot(options); - const base = path.join(root, `dir-${String(nextSyncDirIndex)}`); + const root = acquireSyncPrefixRoot(options); + const base = path.join(root.path, `dir-${String(nextSyncDirIndex)}`); nextSyncDirIndex += 1; - fsSync.mkdirSync(base, { recursive: true }); - const dir = options.subdir ? path.join(base, options.subdir) : base; - if (options.subdir) { - fsSync.mkdirSync(dir, { recursive: true }); - } try { + fsSync.mkdirSync(base, { recursive: true }); + const dir = options.subdir ? path.join(base, options.subdir) : base; + if (options.subdir) { + fsSync.mkdirSync(dir, { recursive: true }); + } return run(dir); } finally { fsSync.rmSync(base, { @@ -133,5 +188,6 @@ export function withTempDirSync( maxRetries: 20, retryDelay: 25, }); + releaseSyncPrefixRoot(options); } } diff --git a/test/scripts/changed-lanes.test.ts b/test/scripts/changed-lanes.test.ts index 6ae762aff16..4d1f6add7f1 100644 --- a/test/scripts/changed-lanes.test.ts +++ b/test/scripts/changed-lanes.test.ts @@ -320,6 +320,26 @@ describe("scripts/changed-lanes", () => { expect(plan.commands.map((command) => command.args[0])).not.toContain("test"); }); + it("routes legacy root sandbox Dockerfile moves to tooling instead of all lanes", () => { + const result = detectChangedLanes([ + "Dockerfile.sandbox", + "Dockerfile.sandbox-browser", + "Dockerfile.sandbox-common", + "scripts/docker/sandbox/Dockerfile", + "scripts/docker/sandbox/Dockerfile.browser", + "scripts/docker/sandbox/Dockerfile.common", + ]); + const plan = createChangedCheckPlan(result); + + expect(result.lanes).toMatchObject({ + tooling: true, + all: false, + }); + expect(plan.commands.map((command) => command.args[0])).toContain("lint:scripts"); + expect(plan.commands.map((command) => command.args[0])).not.toContain("tsgo:all"); + expect(plan.commands.map((command) => command.args[0])).not.toContain("test"); + }); + it("routes live Docker ACP tooling changes through a focused gate", () => { const result = detectChangedLanes([ "scripts/lib/live-docker-auth.sh",