chore: clean up root clutter

This commit is contained in:
Peter Steinberger
2026-05-03 12:20:47 +01:00
parent 4ec1efbcbc
commit 02c2160478
20 changed files with 210 additions and 297 deletions

2
.github/labeler.yml vendored
View File

@@ -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*"

View File

@@ -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);

View File

@@ -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:

8
.gitignore vendored
View File

@@ -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/

View File

@@ -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:<uuid>` 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 <token>
```
Example run:
```bash
openclaw acp --url wss://gateway-host:18789 --token <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:<uuid>` 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",
"<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:<uuid>` 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`

View File

@@ -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.
</Step>
</Steps>

View File

@@ -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 =

View File

@@ -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

View File

@@ -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}"

View File

@@ -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}" \

View File

@@ -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}"

View File

@@ -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',

View File

@@ -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",

View File

@@ -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, {

View File

@@ -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<string> {
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<void>((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([]);
});
});

View File

@@ -3,9 +3,14 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const asyncPrefixRoots = new Map<string, string>();
const pendingAsyncPrefixRoots = new Map<string, Promise<string>>();
const syncPrefixRoots = new Map<string, string>();
type PrefixRootState = {
path: string;
activeCount: number;
};
const asyncPrefixRoots = new Map<string, PrefixRootState>();
const pendingAsyncPrefixRoots = new Map<string, Promise<PrefixRootState>>();
const syncPrefixRoots = new Map<string, PrefixRootState>();
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<string> {
}): Promise<PrefixRootState> {
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<void> {
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<T>(
@@ -56,15 +110,15 @@ export async function withTempDir<T>(
},
run: (dir: string) => Promise<T>,
): Promise<T> {
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<T>(
maxRetries: 20,
retryDelay: 25,
});
await releaseAsyncPrefixRoot(options);
}
}
@@ -116,15 +171,15 @@ export function withTempDirSync<T>(
},
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<T>(
maxRetries: 20,
retryDelay: 25,
});
releaseSyncPrefixRoot(options);
}
}

View File

@@ -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",