From eae025b4a3015e53488a1a3918bda5e69b7d4791 Mon Sep 17 00:00:00 2001 From: ferueda Date: Thu, 23 Jul 2026 12:09:04 -0700 Subject: [PATCH 1/3] feat: add reusable Grove repository runs --- .oxlintrc.json | 8 +- Dockerfile.linear-worker | 15 +- README.md | 5 + compose.linear-automation.yaml | 7 + docs/contributing/architecture.md | 17 + docs/contributing/linear-automation.md | 43 +- docs/contributing/script-command-surface.md | 2 +- docs/contributing/setup-manifest.md | 52 +-- docs/contributing/testing.md | 3 + harness.json | 8 + lib/config/schema.ts | 2 + lib/repository/config-schema.ts | 17 + lib/repository/error.ts | 57 +++ lib/repository/git.ts | 189 +++++++++ lib/repository/repository.test.ts | 404 +++++++++++++++++++ lib/repository/repository.ts | 416 ++++++++++++++++++++ lib/repository/setup.ts | 78 ++++ lib/repository/types.ts | 50 +++ package.json | 1 + pnpm-lock.yaml | 155 ++++++++ scripts/smoke-linear-automation-compose.ts | 93 +++++ test/config.test.ts | 30 ++ test/docs-contracts.test.ts | 9 +- test/import-boundaries.test.ts | 7 +- 24 files changed, 1619 insertions(+), 49 deletions(-) create mode 100644 lib/repository/config-schema.ts create mode 100644 lib/repository/error.ts create mode 100644 lib/repository/git.ts create mode 100644 lib/repository/repository.test.ts create mode 100644 lib/repository/repository.ts create mode 100644 lib/repository/setup.ts create mode 100644 lib/repository/types.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 3d291c4..2f43bd9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -151,8 +151,12 @@ "message": "Repository primitives must not depend on tracker code." }, { - "regex": "^\\.\\./(?:\\.\\./)*(?:linear(?:/|-)|inngest(?:/|$)|triage(?:/|$)|spec(?:/|$)|implementation(?:/|$))", - "message": "Repository primitives own isolated execution and publication, not tracker or domain policy." + "regex": "^@octokit(?:/|$)", + "message": "Repository workspace primitives must not own GitHub publication." + }, + { + "regex": "^\\.\\./(?:\\.\\./)*(?:linear(?:/|-)|inngest(?:/|$)|triage(?:/|$)|spec(?:/|$)|implementation(?:/|$)|github(?:/|$)|providers(?:/|$))", + "message": "Repository primitives own isolated execution and cleanup, not tracker, publication, provider, or domain policy." } ] } diff --git a/Dockerfile.linear-worker b/Dockerfile.linear-worker index 7ef59bc..12dda90 100644 --- a/Dockerfile.linear-worker +++ b/Dockerfile.linear-worker @@ -24,19 +24,28 @@ RUN pnpm build && pnpm prune --prod --ignore-scripts FROM node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d AS runtime ENV CODEX_HOME=/home/node/.codex +ENV COREPACK_HOME=/opt/corepack ENV HOME=/home/node ENV NODE_ENV=production ENV PATH=/app/node_modules/.bin:${PATH} WORKDIR /app -# Git is required by Harness's workspace guard and by Codex repository discovery. +# Git is required by Harness's workspace guard, Grove, and Codex repository discovery. # hadolint ignore=DL3008 RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates git \ && rm -rf /var/lib/apt/lists/* \ + && corepack enable \ + && corepack install --global pnpm@11.9.0 \ && git config --system --add safe.directory /workspace \ - && mkdir -p /home/node/.codex \ - && chown node:node /home/node/.codex + && mkdir -p \ + /home/node/.cache/pnpm \ + /home/node/.codex \ + /home/node/.harness/repositories \ + && chown -R node:node \ + /home/node/.cache \ + /home/node/.codex \ + /home/node/.harness COPY --from=build --chown=node:node /build/package.json ./package.json COPY --from=build --chown=node:node /build/node_modules ./node_modules diff --git a/README.md b/README.md index daa2a93..a4900c2 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ The worker uses stable IDs and its triage profile from the target repository's [Linear automation guide](docs/contributing/linear-automation.md) for the Compose setup, health checks, and smoke tests. +Write-capable consumers use the standalone Grove-backed repository primitive. +It leases reusable writable worktrees, reruns repository setup against warm +ignored dependencies, and resets completed work without owning commits, pull +requests, or Linear policy. + ## Configure Agents `harness.json` stores target-repository defaults. `harness init` starts with the diff --git a/compose.linear-automation.yaml b/compose.linear-automation.yaml index 5e1c969..d7a6690 100644 --- a/compose.linear-automation.yaml +++ b/compose.linear-automation.yaml @@ -38,6 +38,8 @@ services: environment: CODEX_API_KEY: ${CODEX_API_KEY:-} CODEX_HOME: /home/node/.codex + COREPACK_HOME: /opt/corepack + HARNESS_REPOSITORY_ROOT: /home/node/.harness/repositories HARNESS_WORKER_HOST: 0.0.0.0 HARNESS_WORKER_PORT: "8080" HOME: /home/node @@ -47,6 +49,7 @@ services: INNGEST_EVENT_KEY: ${INNGEST_EVENT_KEY:?set INNGEST_EVENT_KEY} INNGEST_SIGNING_KEY: ${INNGEST_SIGNING_KEY:?set INNGEST_SIGNING_KEY} LINEAR_API_KEY: ${LINEAR_API_KEY:?set LINEAR_API_KEY} + PNPM_CONFIG_STORE_DIR: /home/node/.cache/pnpm/store init: true networks: - linear-automation @@ -60,6 +63,8 @@ services: target: /workspace read_only: true - codex-home:/home/node/.codex + - package-manager-cache:/home/node/.cache/pnpm + - repository-data:/home/node/.harness/repositories networks: linear-automation: @@ -67,3 +72,5 @@ networks: volumes: codex-home: inngest-data: + package-manager-cache: + repository-data: diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index 9ba6c2e..d2bc37c 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -138,6 +138,7 @@ real consumers expose the same stable contract. | `lib/linear/` | Standalone, JSON-safe Linear read, write, pagination, and webhook primitives without domain or delivery policy | | `lib/triage/` | Triage prompt, structured decision schema, and provider-independent operation | | `lib/spec/` | Spec prompt, structured result schema, issue-key artifact validation, and provider-independent operation | +| `lib/repository/` | Grove-backed writable repository leases, safe setup, change inspection, and reset cleanup | | `lib/linear-automation/` | Linear readiness policy, application event contracts, Inngest functions, worker config, and process hosting | | `lib/skills/` | Packaged-skill installation support | | `skills/` | Packaged skills installed into target repositories | @@ -185,6 +186,20 @@ Workflows and operations depend on the shared provider interface. Provider auth, SDK or CLI invocation, streaming, session continuation, model policy, and sandbox details stay under `providers/` or provider-scoped configuration. +### Repository boundary + +The repository module adapts Grove behind plain `RepositoryBase` and +`RepositoryRun` handles. It resolves mutable base refs to exact commits, gives +each durable work ID a writable branch lease, runs the target repository's +configured setup command after acquisition, reports plain Git changes, and +returns successful worktrees to a reusable pool. + +Setup runs with a fixed environment allowlist, so Linear, Inngest, Codex, and +GitHub credentials are not passed to target-repository scripts. Reset cleanup +removes tracked and ordinary untracked work while retaining ignored dependency +caches such as `node_modules`. The module does not commit, push, open pull +requests, update Linear, or choose Spec and implementation policy. + ### Delivery boundary Inngest functions reload external truth before deciding or projecting. Event @@ -210,6 +225,8 @@ check the runtime schema, exported schema, prompt, and consumer together. | Self-hosted Inngest SQLite volume | Local delivery history, retry state, function metadata, and traces | | Protected worker environment file outside a repo | Linear, Inngest, and optional Codex credentials for one deployment | | Dedicated worker Codex credential volume | Optional unattended ChatGPT-backed Codex login, separate from the host account | +| Repository data volume | Writable controller clones, Grove leases, worktrees, and warm ignored dependencies | +| Package-manager cache volume | Reusable package downloads shared by setup runs for one Compose deployment | Review artifacts are ignored workspace-local state. Self-hosted deployment state and credentials are external user data. Neither belongs in Git. diff --git a/docs/contributing/linear-automation.md b/docs/contributing/linear-automation.md index fbddd8f..727302d 100644 --- a/docs/contributing/linear-automation.md +++ b/docs/contributing/linear-automation.md @@ -55,11 +55,19 @@ spec needing revision returns as Open + Spec. ## Configure the target repository The target repository's `harness.json` owns stable team, project, state, and -Agent action label IDs plus the triage execution profile. It contains no -secrets. +Agent action label IDs, the triage execution profile, and repository-run setup. +It contains no secrets. ```json { + "repositoryRuns": { + "remote": "https://github.com/example/project.git", + "maxTrees": 2, + "setup": { + "command": ["pnpm", "install", "--frozen-lockfile", "--prefer-offline"], + "timeoutMs": 900000 + } + }, "linearAutomation": { "readiness": { "teamId": "team-id", @@ -98,8 +106,16 @@ worker can reuse it without adding a shared scheduler or project registry. The deployment contains one self-hosted Inngest service and one Harness worker. It is intentionally scoped to one configured target repository. Inngest keeps -its SQLite database in a named volume, while the worker reads the target checkout -through a read-only bind mount. +its SQLite database in a named volume, while the worker reads the target +checkout through a read-only bind mount. + +Write-capable operations use a separate persistent repository-data volume. It +contains a writable controller clone plus Grove's reusable worktrees; the +read-only source bind is never fetched, checked out, or cleaned. Grove reset +keeps ignored `node_modules`, and a separate package-manager-cache volume keeps +downloaded packages. The setup command runs after every acquisition so those +warm dependencies are reconciled with the checked-out lockfile before an agent +receives the workspace. Keep deployment secrets outside the target repository. The triage agent can read the workspace, so an ignored file inside that workspace is not a safe secret @@ -169,15 +185,16 @@ docker compose --env-file "$LINEAR_AUTOMATION_ENV" --file "$HARNESS_ROOT/compose # Follow logs docker compose --env-file "$LINEAR_AUTOMATION_ENV" --file "$HARNESS_ROOT/compose.linear-automation.yaml" logs --follow -# Stop containers while preserving SQLite and Codex credentials +# Stop containers while preserving SQLite, repository data, caches, and Codex credentials docker compose --env-file "$LINEAR_AUTOMATION_ENV" --file "$HARNESS_ROOT/compose.linear-automation.yaml" down ``` Do not add `--volumes` to normal shutdown. It deliberately deletes Inngest -history and the dedicated Codex login. Both services use restart policies, and -the Connect worker automatically reconnects after an Inngest restart. The -worker's stop grace period is longer than the configured maximum triage runtime -so an active agent step can drain. +history, repository leases and warm dependencies, package-manager caches, and +the dedicated Codex login. Both services use restart policies, and the Connect +worker automatically reconnects after an Inngest restart. The worker's stop +grace period is longer than the configured maximum triage runtime so an active +agent step can drain. To run another target project, create another environment file with a distinct `COMPOSE_PROJECT_NAME`, workspace path, and dashboard port. Keep one configured @@ -216,5 +233,9 @@ SQLite state on success. It does not call live Linear or a real model. `make smoke-linear-automation-compose` is the explicit Docker packaging smoke. It validates and builds the Compose model, starts both containers on a blocked- egress smoke network, checks service health, restarts each service, proves the -worker reconnects and accepted event history survives, then removes all -disposable containers and volumes. It also does not call live Linear or a model. +worker reconnects and accepted event history survives, and runs a real Grove +repository lease across worker restart. The repository probe verifies +post-acquire setup, secret-free setup environment, dirty-work recovery, change +inspection, reset cleanup, warm ignored dependency reuse, and persistent named +volumes. The smoke then removes all disposable containers and volumes. It does +not call live Linear, GitHub, or a model. diff --git a/docs/contributing/script-command-surface.md b/docs/contributing/script-command-surface.md index ac5e3b5..d8c4e0a 100644 --- a/docs/contributing/script-command-surface.md +++ b/docs/contributing/script-command-surface.md @@ -17,7 +17,7 @@ and [Setup Manifest](./setup-manifest.md) for generated artifacts and auth. | Source CLI | `bin/harness.ts` | `harness init`, `harness linear worker`, `harness run change-review`, `harness run plan-review`, `harness runs prune`, `harness models`, `harness skills install` | User-facing reviews, the persistent Linear automation worker, run cleanup, model discovery, and skill installation. | | Distribution smoke | `scripts/smoke-dist.ts` | `pnpm smoke:dist`, `make smoke-dist` | Verify built CLI behavior, init shim creation, skills install, dry-run review metadata, and handoff artifacts. | | Linear automation smoke | `scripts/smoke-linear-automation.ts` | `pnpm smoke:linear-automation`, `make smoke-linear-automation` | Verify self-hosted Inngest startup, Connect registration, polling, revision routing, triage, and projection through fake boundaries. | -| Linear Compose smoke | `scripts/smoke-linear-automation-compose.ts` | `pnpm smoke:linear-automation-compose`, `make smoke-linear-automation-compose` | Verify the worker image and self-hosted Compose packaging, health, restart, reconnection, and persistence boundaries without live traffic. | +| Linear Compose smoke | `scripts/smoke-linear-automation-compose.ts` | `pnpm smoke:linear-automation-compose`, `make smoke-linear-automation-compose` | Verify the worker image, self-hosted Compose packaging, health, restart, reconnection, real Grove lease recovery, warm reuse, and persistence without live traffic. | | Codex request proxy | `scripts/codex-proxy.mjs` | `pnpm codex:proxy` | Inspect local Codex Responses API requests, request byte contributors, tool definition size, and reported input-token usage. | | User install shim | `install` | `harness ...` from the installed user-level shim | Install or refresh the user command, usually under `~/.local/bin/harness`. | | Target-repo shim | `harness init` | `.harness/bin/harness ...` inside the target repo | Pin a target repo to the Harness checkout that initialized it. | diff --git a/docs/contributing/setup-manifest.md b/docs/contributing/setup-manifest.md index 332c578..90f0a02 100644 --- a/docs/contributing/setup-manifest.md +++ b/docs/contributing/setup-manifest.md @@ -22,9 +22,11 @@ artifact lifecycle, and directory ownership. `harness linear worker` reads stable project, team, workflow-state, and Agent-action label IDs from the target repository's `linearAutomation` section. The first consumer requires `triage.agent: "codex"` and also accepts a timeout -plus optional model and reasoning overrides. Unsupported providers fail during -startup configuration loading, before Connect accepts work. The section does -not contain secrets. +plus optional model and reasoning overrides. `repositoryRuns` defines the +credential-free Git remote, pool size, and idempotent setup command used by +write-capable consumers. Unsupported providers and invalid repository setup +fail during startup configuration loading, before Connect accepts work. These +sections do not contain secrets. The worker requires `LINEAR_API_KEY`. Self-hosted Inngest requires `INNGEST_EVENT_KEY`, `INNGEST_SIGNING_KEY`, `INNGEST_DEV=0`, and @@ -65,12 +67,16 @@ hook mutation, and does not copy or link dependencies from another worktree or create a worktree-local package store. `.pnpm-store/` is also ignored as defense in depth so transient cache files cannot become candidate source evidence. Manual executors run setup after their before-edit checkpoint is acknowledged. -Workspace hosts may run the same repository-owned command as an acquire hook. -Readiness does not replace the final `make check` gate. +Automated repository runs execute the same repository-owned setup through +Harness's secret-filtering setup primitive after Grove acquisition. They do not +use Grove shell hooks because those inherit the worker environment. Readiness +does not replace the final `make check` gate. Keep the self-hosted Inngest database on a persistent worker filesystem or -Compose volume. The worker itself is stateless apart from an optional dedicated -Codex credential volume. +Compose volume. Keep Grove controller clones, leases, worktrees, and warm +ignored dependencies in the repository-data volume. Keep package downloads in +the package-manager-cache volume. The optional Codex credential volume remains +separate from both. ## Hook activation @@ -91,22 +97,22 @@ Git hooks. ## Generated artifacts and ownership -| Path | Created by | Repo boundary | Commit policy | Notes | -| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `.git/hooks/pre-commit` in the harness checkout | `pnpm install --frozen-lockfile`, `pnpm exec simple-git-hooks` | Harness repo local Git metadata | Do not commit | Runs staged format/lint fixes and `pnpm typecheck` before local commits. | -| `node_modules/` and defensive `.pnpm-store/` ignore in a Harness checkout or worktree | `pnpm install --frozen-lockfile`, `make setup-worktree` | Harness checkout or isolated executor worktree | Ignored; do not commit | Repository-owned Make gates use the ordinary shared pnpm store and skip shared Git-hook mutation during setup. | -| `dist/` in the harness checkout | `pnpm build`, `make build`, `pnpm smoke:dist`, `make smoke-dist`, `make check`, `make check-v`, `make check-ci`, `pnpm check`, `pnpm check:v`, `pnpm check:ci` | Harness repo local build output | Ignored; do not commit | Built JavaScript used by smoke tests and future package paths. | -| OS temp `harness-gate-*` dirs or `GATE_LOG_DIR` | Wrapped Make targets via `scripts/run-gate-step.ts` | Harness repo local gate diagnostics | Do not commit; review before sharing | Failed gate logs are kept for diagnosis. Successful logs are deleted unless `KEEP_GATE_LOGS=1`. | -| `logs/codex-proxy/` in the harness checkout | `pnpm codex:proxy` / `scripts/codex-proxy.mjs` | Local Codex Responses API request audits | Ignored; do not commit | Contains Markdown request audits and, when `CODEX_PROXY_WRITE_RAW=1`, parsed request JSON. Treat as sensitive because captured prompts, tool definitions, and request metadata may include private context. | -| `.harness/` in the harness checkout | Dogfooded `harness run ...`, `make smoke-dist`, `pnpm smoke:dist`, `make check`, `pnpm check` | Harness repo local run state | Ignored; do not commit | Contains local workflow artifacts when running against this repo. Smoke and full check paths use dry-run `change-review` and leave ignored run directories. | -| `harness.json` in a target repo | `harness init` | Target repo config | Target repo decides | Stores repo-local defaults such as base branch and default agent. | -| `.harness/bin/harness` in a target repo | `harness init` | Target repo local shim | Ignored; do not commit | Points back to the harness checkout that initialized the repo. | -| `.harness/runs/reviews//` in a workspace | `harness run change-review`, `harness run plan-review`, dry-run review commands | Workspace-local run state, either external target repo or harness checkout | Ignored; do not commit | Holds context, prompts, reviewer JSON, streams, events, `summary.md`, and `meta.json`. | -| Protected Linear automation environment file outside a repo | Deployment operator | Local deployment secrets | User data; do not commit | Holds Linear, Inngest, and optional Codex keys with owner-only permissions. Stable IDs stay in target-repo `harness.json`. | -| Compose volumes for Inngest SQLite and optional Codex credentials | `compose.linear-automation.yaml` | Local deployment state | User data; do not commit | Preserves Inngest history and unattended Codex login across normal container restarts. | -| `.agents/skills/` in a target repo | `harness skills install` | Target repo local skill installs | Target repo decides | Live installs copy packaged skills into the target repo. | -| `~/.codex/state_5.sqlite` | Codex CLI | User-level Codex state | Do not commit | Source of truth for Codex session indexing. | -| `~/.codex/sqlite/state_5.sqlite` | Older or alternate Codex CLI state layout | User-level Codex state | Do not commit | Missing-root fallback for Codex session indexing. | +| Path | Created by | Repo boundary | Commit policy | Notes | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.git/hooks/pre-commit` in the harness checkout | `pnpm install --frozen-lockfile`, `pnpm exec simple-git-hooks` | Harness repo local Git metadata | Do not commit | Runs staged format/lint fixes and `pnpm typecheck` before local commits. | +| `node_modules/` and defensive `.pnpm-store/` ignore in a Harness checkout or worktree | `pnpm install --frozen-lockfile`, `make setup-worktree` | Harness checkout or isolated executor worktree | Ignored; do not commit | Repository-owned Make gates use the ordinary shared pnpm store and skip shared Git-hook mutation during setup. | +| `dist/` in the harness checkout | `pnpm build`, `make build`, `pnpm smoke:dist`, `make smoke-dist`, `make check`, `make check-v`, `make check-ci`, `pnpm check`, `pnpm check:v`, `pnpm check:ci` | Harness repo local build output | Ignored; do not commit | Built JavaScript used by smoke tests and future package paths. | +| OS temp `harness-gate-*` dirs or `GATE_LOG_DIR` | Wrapped Make targets via `scripts/run-gate-step.ts` | Harness repo local gate diagnostics | Do not commit; review before sharing | Failed gate logs are kept for diagnosis. Successful logs are deleted unless `KEEP_GATE_LOGS=1`. | +| `logs/codex-proxy/` in the harness checkout | `pnpm codex:proxy` / `scripts/codex-proxy.mjs` | Local Codex Responses API request audits | Ignored; do not commit | Contains Markdown request audits and, when `CODEX_PROXY_WRITE_RAW=1`, parsed request JSON. Treat as sensitive because captured prompts, tool definitions, and request metadata may include private context. | +| `.harness/` in the harness checkout | Dogfooded `harness run ...`, `make smoke-dist`, `pnpm smoke:dist`, `make check`, `pnpm check` | Harness repo local run state | Ignored; do not commit | Contains local workflow artifacts when running against this repo. Smoke and full check paths use dry-run `change-review` and leave ignored run directories. | +| `harness.json` in a target repo | `harness init` | Target repo config | Target repo decides | Stores repo-local defaults such as base branch and default agent. | +| `.harness/bin/harness` in a target repo | `harness init` | Target repo local shim | Ignored; do not commit | Points back to the harness checkout that initialized the repo. | +| `.harness/runs/reviews//` in a workspace | `harness run change-review`, `harness run plan-review`, dry-run review commands | Workspace-local run state, either external target repo or harness checkout | Ignored; do not commit | Holds context, prompts, reviewer JSON, streams, events, `summary.md`, and `meta.json`. | +| Protected Linear automation environment file outside a repo | Deployment operator | Local deployment secrets | User data; do not commit | Holds Linear, Inngest, and optional Codex keys with owner-only permissions. Stable IDs stay in target-repo `harness.json`. | +| Compose volumes for Inngest SQLite, repository data, package caches, and optional Codex credentials | `compose.linear-automation.yaml` | Local deployment state | User data; do not commit | Preserves Inngest history, Grove leases and warm ignored dependencies, package downloads, and unattended Codex login across normal container restarts. | +| `.agents/skills/` in a target repo | `harness skills install` | Target repo local skill installs | Target repo decides | Live installs copy packaged skills into the target repo. | +| `~/.codex/state_5.sqlite` | Codex CLI | User-level Codex state | Do not commit | Source of truth for Codex session indexing. | +| `~/.codex/sqlite/state_5.sqlite` | Older or alternate Codex CLI state layout | User-level Codex state | Do not commit | Missing-root fallback for Codex session indexing. | Review artifacts are workspace-relative ignored local state: they live under an external target repo when reviewing another repo, and under this harness checkout diff --git a/docs/contributing/testing.md b/docs/contributing/testing.md index 74015d0..9e73052 100644 --- a/docs/contributing/testing.md +++ b/docs/contributing/testing.md @@ -37,6 +37,7 @@ docs should link here. Exact command ownership lives in | Static and module | Types, parsers, validators, mappers, policies, and helpers | | Workflow and operation | Review behavior, domain decisions, idempotency, and guarded external projections | | Provider adapter | SDK/CLI translation, streaming, sessions, schemas, timeout, and abort behavior | +| Repository integration | Real local Git/Grove leases, setup, resume, inspection, warm reuse, and reset cleanup | | CLI integration | Argument parsing, command selection, structured output, and separate-process behavior | | Repository self-contract | Packaged skills, command inventory, documentation structure, and private-path exclusion | | Distribution smoke | Built package layout, installed entrypoint, generated shim, and basic public wiring | @@ -63,6 +64,8 @@ changed behavior crosses a boundary the cheaper layer cannot observe. in `scripts/smoke-linear-automation-compose.ts`, and gate-output behavior in `test/gate-output.test.ts`. - Keep target-repo fixtures isolated from the Harness checkout and user state. +- Exercise repository lifecycle behavior with real temporary Git remotes and + Grove state. Mock neither Git nor Grove transitions. Use an existing location before inventing another test directory or suffix. diff --git a/harness.json b/harness.json index b28ca9a..bd4bd5a 100644 --- a/harness.json +++ b/harness.json @@ -12,6 +12,14 @@ "approvalPolicy": "never" } }, + "repositoryRuns": { + "remote": "https://github.com/ferueda/harness.git", + "maxTrees": 2, + "setup": { + "command": ["pnpm", "install", "--frozen-lockfile", "--prefer-offline"], + "timeoutMs": 900000 + } + }, "linearAutomation": { "readiness": { "teamId": "e24149c2-1d82-47c7-b886-7f0a8829261b", diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 9de4b14..f6e7fc2 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -6,6 +6,7 @@ import { AGENT_SANDBOX_MODES, } from "../agent/contract.ts"; import { LinearAutomationConfigSchema } from "../linear-automation/config-schema.ts"; +import { RepositoryRunsConfigSchema } from "../repository/config-schema.ts"; export const HarnessConfigSchema = z .object({ @@ -33,6 +34,7 @@ export const HarnessConfigSchema = z .passthrough() .optional(), linearAutomation: LinearAutomationConfigSchema.optional(), + repositoryRuns: RepositoryRunsConfigSchema.optional(), }) .passthrough() .superRefine((config, ctx) => { diff --git a/lib/repository/config-schema.ts b/lib/repository/config-schema.ts new file mode 100644 index 0000000..34f8538 --- /dev/null +++ b/lib/repository/config-schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const RepositoryRunsConfigSchema = z + .object({ + remote: z.string().trim().min(1), + maxTrees: z.number().int().positive().default(2), + setup: z + .object({ + command: z.array(z.string().min(1)).min(1), + timeoutMs: z.number().int().positive(), + }) + .strict(), + }) + .strict(); + +export type RepositoryRunsConfig = z.infer; +export type RepositoryRunsConfigInput = z.input; diff --git a/lib/repository/error.ts b/lib/repository/error.ts new file mode 100644 index 0000000..b68a558 --- /dev/null +++ b/lib/repository/error.ts @@ -0,0 +1,57 @@ +import { GroveError } from "@ferueda/grove"; + +export const REPOSITORY_ERROR_CODES = [ + "invalid_input", + "controller_failed", + "run_conflict", + "pool_exhausted", + "setup_failed", + "inspect_failed", + "cleanup_failed", +] as const; + +export type RepositoryErrorCode = (typeof REPOSITORY_ERROR_CODES)[number]; + +export class RepositoryError extends Error { + readonly code: RepositoryErrorCode; + + constructor(message: string, code: RepositoryErrorCode, options?: ErrorOptions) { + super(message, options); + this.name = "RepositoryError"; + this.code = code; + } +} + +export function normalizeRepositoryError( + operation: "prepare" | "inspect" | "cleanup", + error: unknown, +): RepositoryError { + if (error instanceof RepositoryError) return error; + + if (error instanceof GroveError) { + if (error.code === "GROVE_EXHAUSTED" || error.code === "POOL_EXHAUSTED") { + return new RepositoryError(error.message, "pool_exhausted", { cause: error }); + } + if ( + error.code === "LEASE_CONFLICT" || + error.code === "LEASE_ALREADY_EXISTS" || + error.code === "LEASE_QUARANTINED" || + error.code === "LEASE_BUSY" || + error.code === "ACQUIRE_IN_PROGRESS" || + error.code === "BRANCH_EXISTS" + ) { + return new RepositoryError(error.message, "run_conflict", { cause: error }); + } + } + + const message = error instanceof Error ? error.message : String(error); + const code = + operation === "prepare" + ? "controller_failed" + : operation === "inspect" + ? "inspect_failed" + : "cleanup_failed"; + return new RepositoryError(`Repository ${operation} failed: ${message}`, code, { + cause: error, + }); +} diff --git a/lib/repository/git.ts b/lib/repository/git.ts new file mode 100644 index 0000000..4ae8644 --- /dev/null +++ b/lib/repository/git.ts @@ -0,0 +1,189 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { RepositoryError } from "./error.ts"; +import type { RepositoryChange, RepositoryChangeStatus } from "./types.ts"; + +const execFileAsync = promisify(execFile); +const FULL_GIT_SHA = /^[0-9a-f]{40,64}$/; + +export function assertCredentialFreeRemote(remote: string): void { + if ( + remote.length === 0 || + remote.includes("\u0000") || + remote.includes("\r") || + remote.includes("\n") + ) { + throw new RepositoryError( + "Repository remote must be a non-empty single line.", + "invalid_input", + ); + } + + let parsed: URL; + try { + parsed = new URL(remote); + } catch { + return; + } + + if (parsed.password || parsed.search || parsed.hash) { + throw new RepositoryError( + "Repository remote must not contain credentials, query parameters, or fragments.", + "invalid_input", + ); + } + if ((parsed.protocol === "http:" || parsed.protocol === "https:") && parsed.username) { + throw new RepositoryError( + "Repository remote must not contain HTTP credentials.", + "invalid_input", + ); + } +} + +export async function ensureController(input: { + remote: string; + workspace: string; +}): Promise { + assertCredentialFreeRemote(input.remote); + if (!existsSync(input.workspace)) { + await mkdir(dirname(input.workspace), { recursive: true }); + await runGit(dirname(input.workspace), [ + "clone", + "--no-checkout", + "--origin", + "origin", + "--", + input.remote, + input.workspace, + ]); + } else { + const current = await stat(input.workspace); + if (!current.isDirectory() || !existsSync(join(input.workspace, ".git"))) { + throw new RepositoryError( + `Repository controller is not a Git checkout: ${input.workspace}`, + "controller_failed", + ); + } + } + + const actualRemote = await runGit(input.workspace, ["remote", "get-url", "origin"]); + if (actualRemote !== input.remote) { + throw new RepositoryError( + `Repository controller remote mismatch: expected ${input.remote}, found ${actualRemote}`, + "controller_failed", + ); + } +} + +export async function resolveRemoteBase(input: { + remote: string; + controllerWorkspace: string; + baseRef: string; +}): Promise { + await ensureController({ remote: input.remote, workspace: input.controllerWorkspace }); + await runGit(input.controllerWorkspace, ["fetch", "--prune", "--no-tags", "origin"]); + + const candidates = baseCandidates(input.baseRef); + for (const candidate of candidates) { + try { + const sha = await runGit(input.controllerWorkspace, [ + "rev-parse", + "--verify", + `${candidate}^{commit}`, + ]); + if (FULL_GIT_SHA.test(sha)) return sha; + } catch { + // Try the next unambiguous candidate. + } + } + throw new RepositoryError( + `Repository base ref was not found after fetch: ${input.baseRef}`, + "controller_failed", + ); +} + +export async function inspectGitChanges(workspace: string): Promise { + const output = await runGit(workspace, [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--ignore-submodules=none", + ]); + return parsePorcelain(output); +} + +export async function runGit(workspace: string, args: readonly string[]): Promise { + try { + const { stdout } = await execFileAsync("git", [...args], { + cwd: workspace, + encoding: "utf8", + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + maxBuffer: 8 * 1024 * 1024, + }); + return stdout.trimEnd(); + } catch (error) { + const record = error as Error & { stderr?: string | Buffer }; + const stderr = String(record.stderr ?? "").trim(); + throw new RepositoryError( + stderr || (error instanceof Error ? error.message : String(error)), + "controller_failed", + { cause: error }, + ); + } +} + +function baseCandidates(baseRef: string): readonly string[] { + const ref = baseRef.trim(); + if (!ref) { + throw new RepositoryError("Repository base ref must not be empty.", "invalid_input"); + } + if (ref.startsWith("refs/") || FULL_GIT_SHA.test(ref)) return [ref]; + if (ref.startsWith("origin/")) return [`refs/remotes/${ref}`, ref]; + return [`refs/remotes/origin/${ref}`, ref]; +} + +function parsePorcelain(output: string): readonly RepositoryChange[] { + if (!output) return Object.freeze([]); + + const fields = output.split("\0"); + const changes: RepositoryChange[] = []; + for (let index = 0; index < fields.length; index += 1) { + const field = fields[index]; + if (!field) continue; + if (field.length < 4 || field[2] !== " ") { + throw new RepositoryError("Git returned an invalid porcelain record.", "inspect_failed"); + } + + const xy = field.slice(0, 2); + const path = field.slice(3); + const status = changeStatus(xy); + if (status === "renamed" || status === "copied") { + const previousPath = fields[index + 1]; + if (!previousPath) { + throw new RepositoryError( + "Git returned an incomplete rename or copy record.", + "inspect_failed", + ); + } + changes.push(Object.freeze({ path, previousPath, status })); + index += 1; + continue; + } + changes.push(Object.freeze({ path, status })); + } + return Object.freeze(changes); +} + +function changeStatus(xy: string): RepositoryChangeStatus { + if (xy === "??") return "untracked"; + if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(xy)) return "conflicted"; + if (xy.includes("R")) return "renamed"; + if (xy.includes("C")) return "copied"; + if (xy.includes("D")) return "deleted"; + if (xy.includes("A")) return "added"; + return "modified"; +} diff --git a/lib/repository/repository.test.ts b/lib/repository/repository.test.ts new file mode 100644 index 0000000..7ddde84 --- /dev/null +++ b/lib/repository/repository.test.ts @@ -0,0 +1,404 @@ +import { + createGrove, + readLeaseFirstState, + transitionLease, + transitionSlot, + writeLeaseFirstState, +} from "@ferueda/grove"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "vitest"; +import { RepositoryError } from "./error.ts"; +import { createRepository } from "./repository.ts"; +import { repositorySetupEnvironment } from "./setup.ts"; +import type { CreateRepositoryOptions, RepositoryRun } from "./types.ts"; + +const roots: string[] = []; +const SETUP_STATE = "node_modules/.harness-setup.json"; +const SETUP_SCRIPT = [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const cp = require("node:child_process");', + `const target = path.join(process.cwd(), ${JSON.stringify(SETUP_STATE)});`, + "fs.mkdirSync(path.dirname(target), { recursive: true });", + "let previous = { calls: 0 };", + 'try { previous = JSON.parse(fs.readFileSync(target, "utf8")); } catch {}', + 'const branch = cp.execFileSync("git", ["branch", "--show-current"], { encoding: "utf8" }).trim();', + "const forbidden = Object.keys(process.env).filter((key) => /^(?:LINEAR|INNGEST|GITHUB|CODEX)_/.test(key));", + "fs.writeFileSync(target, JSON.stringify({ calls: previous.calls + 1, branch, forbidden, ci: process.env.CI }));", +].join("\n"); + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("repository runs resolve an exact base, reacquire dirty work, and inspect plain changes", async () => { + const fixture = createFixture(); + const repository = createTestRepository(fixture); + const base = await repository.resolveBase({ baseRef: "main" }); + const run = await repository.prepareRun({ + id: "run-inspect", + base, + branch: "codex/inspect", + }); + + expect(run).toEqual({ + version: 1, + id: "run-inspect", + workspace: run.workspace, + remote: fixture.remote, + baseRef: "main", + baseSha: base.baseSha, + branch: "codex/inspect", + }); + expect(git(run.workspace, ["rev-parse", "HEAD"])).toBe(base.baseSha); + expect(readSetup(run)).toEqual({ + calls: 1, + branch: "codex/inspect", + forbidden: [], + ci: "1", + }); + expect(existsSync(join(fixture.source, "node_modules"))).toBe(false); + + writeFileSync(join(run.workspace, "README.md"), "# changed\n", "utf8"); + unlinkSync(join(run.workspace, "delete-me.txt")); + renameSync(join(run.workspace, "rename-me.txt"), join(run.workspace, "renamed.txt")); + git(run.workspace, ["add", "-A"]); + writeFileSync(join(run.workspace, "untracked.txt"), "new\n", "utf8"); + + expect(await repository.inspectChanges(run)).toEqual( + expect.arrayContaining([ + { path: "README.md", status: "modified" }, + { path: "delete-me.txt", status: "deleted" }, + { path: "renamed.txt", previousPath: "rename-me.txt", status: "renamed" }, + { path: "untracked.txt", status: "untracked" }, + ]), + ); + + const reacquired = await repository.prepareRun({ + id: run.id, + base, + branch: run.branch, + }); + expect(reacquired.workspace).toBe(run.workspace); + expect(readFileSync(join(reacquired.workspace, "README.md"), "utf8")).toBe("# changed\n"); + expect(readSetup(reacquired).calls).toBe(2); + + await expect( + repository.prepareRun({ + id: run.id, + base, + branch: "codex/a-different-target", + }), + ).rejects.toMatchObject({ code: "run_conflict" }); + + advanceRemote(fixture); + expect(git(run.workspace, ["rev-parse", "HEAD"])).toBe(base.baseSha); + const newerBase = await repository.resolveBase({ baseRef: "main" }); + expect(newerBase.baseSha).not.toBe(base.baseSha); +}); + +test("repository cleanup reuses a bounded warm pool while preserving ignored dependencies", async () => { + const fixture = createFixture(); + const repository = createTestRepository(fixture, { maxTrees: 2 }); + const base = await repository.resolveBase({ baseRef: "main" }); + const first = await repository.prepareRun({ + id: "run-first", + base, + branch: "codex/first", + }); + const second = await repository.prepareRun({ + id: "run-second", + base, + branch: "codex/second", + }); + expect(second.workspace).not.toBe(first.workspace); + + await expect( + repository.prepareRun({ + id: "run-over-capacity", + base, + branch: "codex/over-capacity", + }), + ).rejects.toMatchObject({ code: "pool_exhausted" }); + + writeFileSync(join(first.workspace, "README.md"), "# disposable\n", "utf8"); + writeFileSync(join(first.workspace, "agent-output.txt"), "remove me\n", "utf8"); + expect(await repository.cleanupRun(first)).toEqual({ status: "released" }); + expect(await repository.cleanupRun(first)).toEqual({ status: "already-clean" }); + + const afterRestart = createTestRepository(fixture, { maxTrees: 2 }); + const reused = await afterRestart.prepareRun({ + id: "run-reused", + base, + branch: "codex/reused", + }); + expect(reused.workspace).toBe(first.workspace); + expect(readFileSync(join(reused.workspace, "README.md"), "utf8")).toBe("# Fixture\n"); + expect(existsSync(join(reused.workspace, "agent-output.txt"))).toBe(false); + expect(readSetup(reused).calls).toBe(2); + + expect(await afterRestart.cleanupRun(reused)).toEqual({ status: "released" }); + expect(await afterRestart.cleanupRun(second)).toEqual({ status: "released" }); +}); + +test("setup failure keeps the same lease and retries with a secret-free environment", async () => { + const fixture = createFixture(); + const failOnceScript = [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const marker = path.join(process.cwd(), "node_modules/.failed-once");', + "fs.mkdirSync(path.dirname(marker), { recursive: true });", + 'if (!fs.existsSync(marker)) { fs.writeFileSync(marker, "failed"); process.exit(23); }', + 'fs.writeFileSync(path.join(process.cwd(), "node_modules/.ready"), "ready");', + ].join("\n"); + const options = repositoryOptions(fixture, { + setup: { + command: [process.execPath, "-e", failOnceScript], + timeoutMs: 10_000, + }, + }); + const repository = createRepository(options); + const base = await repository.resolveBase({ baseRef: "main" }); + const input = { id: "run-setup-retry", base, branch: "codex/setup-retry" }; + + await expect(repository.prepareRun(input)).rejects.toMatchObject({ code: "setup_failed" }); + const grove = await createGrove({ + repoRoot: fixture.controller, + groveDir: fixture.pool, + maxTrees: 2, + fetchOnAcquire: false, + }); + const retained = await grove.inspect(input.id); + expect(retained?.state).toBe("leased"); + + const run = await repository.prepareRun(input); + expect(run.workspace).toBe(retained?.path); + expect(readFileSync(join(run.workspace, "node_modules/.ready"), "utf8")).toBe("ready"); + expect(await repository.cleanupRun(run)).toEqual({ status: "released" }); + + expect( + repositorySetupEnvironment({ + PATH: "/usr/bin", + HOME: "/tmp/home", + LINEAR_API_KEY: "linear-secret", + INNGEST_SIGNING_KEY: "inngest-secret", + GITHUB_TOKEN: "github-secret", + CODEX_API_KEY: "codex-secret", + }), + ).toEqual({ + CI: "1", + GIT_TERMINAL_PROMPT: "0", + HOME: "/tmp/home", + PATH: "/usr/bin", + }); +}); + +test("interrupted Grove acquire and cleanup resume through matching repair intents", async () => { + const fixture = createFixture(); + const repository = createTestRepository(fixture); + const base = await repository.resolveBase({ baseRef: "main" }); + const first = await repository.prepareRun({ + id: "run-repair-acquire", + base, + branch: "codex/repair-acquire", + }); + const state = await readLeaseFirstState(fixture.pool, { repoRoot: fixture.controller }); + const leaseIndex = state.leases.findIndex((lease) => lease.leaseId === first.id); + const leased = state.leases[leaseIndex]; + if (!leased?.target) throw new Error("expected acquired Grove target"); + state.leases[leaseIndex] = { + ...leased, + state: "preparing", + target: undefined, + acquiredHeadSha: undefined, + currentHeadSha: undefined, + pendingAcquire: { + target: leased.target, + startedAt: new Date().toISOString(), + postCreatePending: false, + }, + }; + await writeLeaseFirstState(fixture.pool, state); + + const repairedAcquire = await repository.prepareRun({ + id: first.id, + base, + branch: first.branch, + }); + expect(repairedAcquire.workspace).toBe(first.workspace); + + const cleanupState = await readLeaseFirstState(fixture.pool, { + repoRoot: fixture.controller, + }); + const cleanupLeaseIndex = cleanupState.leases.findIndex( + (lease) => lease.leaseId === repairedAcquire.id, + ); + const cleanupLease = cleanupState.leases[cleanupLeaseIndex]; + if (!cleanupLease) throw new Error("expected Grove cleanup lease"); + cleanupState.leases[cleanupLeaseIndex] = transitionLease(cleanupLease, { + type: "RELEASE_START", + cleanup: { cleanup: "reset", resetTo: base.baseSha, force: true }, + })!; + await writeLeaseFirstState(fixture.pool, cleanupState); + + expect(await repository.cleanupRun(repairedAcquire)).toEqual({ status: "released" }); + + const quarantined = await repository.prepareRun({ + id: "run-repair-quarantine", + base, + branch: "codex/repair-quarantine", + }); + const quarantineState = await readLeaseFirstState(fixture.pool, { + repoRoot: fixture.controller, + }); + const quarantineLeaseIndex = quarantineState.leases.findIndex( + (lease) => lease.leaseId === quarantined.id, + ); + const quarantineLease = quarantineState.leases[quarantineLeaseIndex]; + const quarantineSlotIndex = quarantineState.slots.findIndex( + (slot) => slot.slotName === quarantineLease?.slotName, + ); + const quarantineSlot = quarantineState.slots[quarantineSlotIndex]; + if (!quarantineLease || !quarantineSlot) throw new Error("expected Grove quarantine state"); + const releasing = transitionLease(quarantineLease, { + type: "RELEASE_START", + cleanup: { cleanup: "reset", resetTo: base.baseSha, force: true }, + }); + if (!releasing) throw new Error("expected releasing lease"); + quarantineState.leases[quarantineLeaseIndex] = transitionLease(releasing, { + type: "RELEASE_FAILED", + reason: "simulated interruption", + failedPhase: "reset", + })!; + const quarantinedSlot = transitionSlot(quarantineSlot, { + type: "QUARANTINE", + reason: "simulated interruption", + }); + if (!quarantinedSlot) throw new Error("expected quarantined Grove slot"); + quarantineState.slots[quarantineSlotIndex] = quarantinedSlot; + await writeLeaseFirstState(fixture.pool, quarantineState); + + expect(await repository.cleanupRun(quarantined)).toEqual({ status: "released" }); +}); + +test("repository rejects credential-bearing remotes and overlapping storage", async () => { + const fixture = createFixture(); + const credentialed = createRepository({ + ...repositoryOptions(fixture), + remote: "https://token@example.com/repository.git", + }); + await expect(credentialed.resolveBase({ baseRef: "main" })).rejects.toMatchObject({ + code: "invalid_input", + }); + + expect(() => + createRepository({ + ...repositoryOptions(fixture), + poolDirectory: join(fixture.controller, "pool"), + }), + ).toThrow(RepositoryError); +}); + +type Fixture = Readonly<{ + root: string; + remote: string; + source: string; + controller: string; + pool: string; +}>; + +function createFixture(): Fixture { + const root = mkdtempSync(join(tmpdir(), "harness-repository-")); + roots.push(root); + const remote = join(root, "remote.git"); + const source = join(root, "source"); + const controller = join(root, "storage", "controller"); + const pool = join(root, "storage", "grove"); + + git(root, ["init", "--bare", remote]); + git(root, ["clone", remote, source]); + git(source, ["config", "user.email", "harness@example.com"]); + git(source, ["config", "user.name", "Harness Test"]); + writeFileSync(join(source, ".gitignore"), "node_modules/\n", "utf8"); + writeFileSync(join(source, "README.md"), "# Fixture\n", "utf8"); + writeFileSync(join(source, "delete-me.txt"), "delete\n", "utf8"); + writeFileSync(join(source, "rename-me.txt"), "rename\n", "utf8"); + git(source, ["add", "."]); + git(source, ["commit", "-m", "Initialize fixture"]); + git(source, ["branch", "-M", "main"]); + git(source, ["push", "--set-upstream", "origin", "main"]); + git(remote, ["symbolic-ref", "HEAD", "refs/heads/main"]); + + return Object.freeze({ root, remote, source, controller, pool }); +} + +function createTestRepository(fixture: Fixture, overrides: Partial = {}) { + return createRepository(repositoryOptions(fixture, overrides)); +} + +function repositoryOptions( + fixture: Fixture, + overrides: Partial = {}, +): CreateRepositoryOptions { + return { + remote: fixture.remote, + controllerWorkspace: fixture.controller, + poolDirectory: fixture.pool, + maxTrees: 2, + setup: { + command: [process.execPath, "-e", SETUP_SCRIPT], + timeoutMs: 10_000, + }, + setupEnvironment: { + ...process.env, + LINEAR_API_KEY: "linear-secret", + INNGEST_SIGNING_KEY: "inngest-secret", + GITHUB_TOKEN: "github-secret", + CODEX_API_KEY: "codex-secret", + }, + ...overrides, + }; +} + +function readSetup(run: RepositoryRun): { + calls: number; + branch: string; + forbidden: string[]; + ci: string; +} { + return JSON.parse(readFileSync(join(run.workspace, SETUP_STATE), "utf8")) as { + calls: number; + branch: string; + forbidden: string[]; + ci: string; + }; +} + +function advanceRemote(fixture: Fixture): void { + writeFileSync(join(fixture.source, "newer.txt"), "newer\n", "utf8"); + git(fixture.source, ["add", "newer.txt"]); + git(fixture.source, ["commit", "-m", "Advance fixture"]); + git(fixture.source, ["push", "origin", "main"]); +} + +function git(cwd: string, args: readonly string[]): string { + return execFileSync("git", [...args], { + cwd, + encoding: "utf8", + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} diff --git a/lib/repository/repository.ts b/lib/repository/repository.ts new file mode 100644 index 0000000..86b7341 --- /dev/null +++ b/lib/repository/repository.ts @@ -0,0 +1,416 @@ +import { + createGrove, + type Grove, + type GroveLease, + type GroveLeaseTarget, + type ReleaseResult, +} from "@ferueda/grove"; +import { isAbsolute, relative, resolve, sep } from "node:path"; +import { RepositoryRunsConfigSchema } from "./config-schema.ts"; +import { normalizeRepositoryError, RepositoryError } from "./error.ts"; +import { ensureController, inspectGitChanges, resolveRemoteBase } from "./git.ts"; +import { runRepositorySetup } from "./setup.ts"; +import type { + CreateRepositoryOptions, + RepositoryBase, + RepositoryCleanupResult, + RepositoryRun, + RepositoryService, +} from "./types.ts"; + +const RUN_VERSION = 1; +const DEFAULT_OWNER_ID = "harness-linear-automation"; +const METADATA_KEYS = Object.freeze({ + version: "harness.repository-run.version", + remote: "harness.repository-run.remote", + baseRef: "harness.repository-run.base-ref", + baseSha: "harness.repository-run.base-sha", + branch: "harness.repository-run.branch", +}); + +export function createRepository(options: CreateRepositoryOptions): RepositoryService { + const config = RepositoryRunsConfigSchema.parse({ + remote: options.remote, + maxTrees: options.maxTrees, + setup: options.setup, + }); + const controllerWorkspace = assertAbsolutePath( + "controllerWorkspace", + options.controllerWorkspace, + ); + const poolDirectory = assertAbsolutePath("poolDirectory", options.poolDirectory); + assertSeparatePaths(controllerWorkspace, poolDirectory); + const setupEnvironment = options.setupEnvironment ?? process.env; + const ownerId = options.ownerId?.trim() || DEFAULT_OWNER_ID; + let grovePromise: Promise | undefined; + + async function getGrove(): Promise { + grovePromise ??= createGrove({ + repoRoot: controllerWorkspace, + groveDir: poolDirectory, + maxTrees: config.maxTrees, + fetchOnAcquire: false, + }); + return grovePromise; + } + + async function resolveBase(input: { baseRef: string }): Promise { + try { + const baseRef = input.baseRef.trim(); + const baseSha = await resolveRemoteBase({ + remote: config.remote, + controllerWorkspace, + baseRef, + }); + return Object.freeze({ remote: config.remote, baseRef, baseSha }); + } catch (error) { + throw normalizeRepositoryError("prepare", error); + } + } + + async function prepareRun(input: { + id: string; + base: RepositoryBase; + branch: string; + }): Promise { + try { + assertBase(input.base, config.remote); + const identity = runIdentity(input); + await ensureController({ remote: config.remote, workspace: controllerWorkspace }); + const grove = await getGrove(); + const existing = await grove.inspect(identity.id); + if (existing) + assertRecoverableAcquire(existing, identity, controllerWorkspace, poolDirectory); + + const lease = + existing?.state === "preparing" || + (existing?.state === "quarantined" && existing.pendingAcquire) + ? await grove.repair({ leaseId: identity.id, action: "resume-acquire" }) + : await grove.acquire({ + leaseId: identity.id, + ownerId, + mode: "branch", + branch: identity.branch, + createBranch: { from: identity.base.baseSha, ifExists: "fail" }, + ifLeased: "return-existing", + fetchOnAcquire: false, + metadata: metadataFor(identity), + }); + + if (!isGroveLease(lease)) { + throw new RepositoryError( + `Grove did not return a lease while preparing ${identity.id}.`, + "run_conflict", + ); + } + assertPreparedLease(lease, identity, controllerWorkspace, poolDirectory); + await runRepositorySetup({ + workspace: lease.path, + command: config.setup.command, + timeoutMs: config.setup.timeoutMs, + environment: setupEnvironment, + }); + + return Object.freeze({ + version: RUN_VERSION, + id: identity.id, + workspace: lease.path, + remote: identity.base.remote, + baseRef: identity.base.baseRef, + baseSha: identity.base.baseSha, + branch: identity.branch, + }); + } catch (error) { + throw normalizeRepositoryError("prepare", error); + } + } + + async function inspectChanges(run: RepositoryRun) { + try { + const grove = await getGrove(); + const lease = await grove.inspect(run.id); + if (!lease) { + throw new RepositoryError(`Repository run is not leased: ${run.id}`, "run_conflict"); + } + assertRunLease(lease, run, controllerWorkspace, poolDirectory); + return await inspectGitChanges(run.workspace); + } catch (error) { + throw normalizeRepositoryError("inspect", error); + } + } + + async function cleanupRun(run: RepositoryRun): Promise { + try { + const grove = await getGrove(); + const lease = await grove.inspect(run.id); + if (!lease) return Object.freeze({ status: "already-clean" }); + assertRunLease(lease, run, controllerWorkspace, poolDirectory); + + // Grove reserves a lease to the acquiring PID. A daemon remains alive between + // acquire and release, so verify Grove's own process scan before bypassing only + // that owner reservation with force. + const cleanup = { cleanup: "reset" as const, resetTo: run.baseSha, force: true }; + let result: ReleaseResult; + if (lease.state === "leased") { + await assertNoActiveWorktreeProcesses(grove, run.id); + result = await grove.release(run.id, cleanup); + } else if ( + (lease.state === "releasing" || lease.state === "quarantined") && + matchesCleanup(lease, run.baseSha) + ) { + await assertNoActiveWorktreeProcesses(grove, run.id); + const repaired = await grove.repair({ leaseId: run.id, action: "resume-cleanup" }); + if (!isReleaseResult(repaired)) { + throw new RepositoryError( + `Grove did not finish cleanup for ${run.id}.`, + "cleanup_failed", + ); + } + result = repaired; + } else { + throw new RepositoryError( + `Repository run ${run.id} cannot be cleaned from Grove state ${lease.state}.`, + "run_conflict", + ); + } + + if (result.status !== "released") { + throw new RepositoryError( + `Repository run ${run.id} was not released after reset.`, + "cleanup_failed", + ); + } + return Object.freeze({ status: "released" }); + } catch (error) { + throw normalizeRepositoryError("cleanup", error); + } + } + + return Object.freeze({ resolveBase, prepareRun, inspectChanges, cleanupRun }); +} + +type RunIdentity = Readonly<{ + id: string; + base: RepositoryBase; + branch: string; +}>; + +function runIdentity(input: { id: string; base: RepositoryBase; branch: string }): RunIdentity { + const id = input.id.trim(); + const branch = input.branch.trim(); + if (!id) throw new RepositoryError("Repository run ID must not be empty.", "invalid_input"); + if (!branch) { + throw new RepositoryError("Repository run branch must not be empty.", "invalid_input"); + } + return Object.freeze({ id, base: input.base, branch }); +} + +function metadataFor(identity: RunIdentity): Record { + return { + [METADATA_KEYS.version]: String(RUN_VERSION), + [METADATA_KEYS.remote]: identity.base.remote, + [METADATA_KEYS.baseRef]: identity.base.baseRef, + [METADATA_KEYS.baseSha]: identity.base.baseSha, + [METADATA_KEYS.branch]: identity.branch, + }; +} + +function assertRecoverableAcquire( + lease: GroveLease, + identity: RunIdentity, + controllerWorkspace: string, + poolDirectory: string, +): void { + assertLeaseIdentity(lease, identity, controllerWorkspace, poolDirectory); + if (lease.state === "leased") { + assertTarget(lease.target, identity); + return; + } + if ( + (lease.state === "preparing" || lease.state === "quarantined") && + lease.pendingAcquire && + matchesTarget(lease.pendingAcquire.target, identity) + ) { + return; + } + throw new RepositoryError( + `Repository run ${identity.id} cannot resume from Grove state ${lease.state}.`, + "run_conflict", + ); +} + +function assertPreparedLease( + lease: GroveLease, + identity: RunIdentity, + controllerWorkspace: string, + poolDirectory: string, +): void { + assertLeaseIdentity(lease, identity, controllerWorkspace, poolDirectory); + if (lease.state !== "leased") { + throw new RepositoryError( + `Repository run ${identity.id} was not fully acquired.`, + "run_conflict", + ); + } + assertTarget(lease.target, identity); +} + +function assertRunLease( + lease: GroveLease, + run: RepositoryRun, + controllerWorkspace: string, + poolDirectory: string, +): void { + if (run.version !== RUN_VERSION) { + throw new RepositoryError( + `Unsupported repository run version: ${String(run.version)}`, + "run_conflict", + ); + } + if (resolve(run.workspace) !== resolve(lease.path)) { + throw new RepositoryError(`Repository run path mismatch for ${run.id}.`, "run_conflict"); + } + const identity = runIdentity({ + id: run.id, + branch: run.branch, + base: { + remote: run.remote, + baseRef: run.baseRef, + baseSha: run.baseSha, + }, + }); + assertLeaseIdentity(lease, identity, controllerWorkspace, poolDirectory); + if (lease.target) assertTarget(lease.target, identity); +} + +function assertLeaseIdentity( + lease: GroveLease, + identity: RunIdentity, + controllerWorkspace: string, + poolDirectory: string, +): void { + if (lease.leaseId !== identity.id || resolve(lease.repoRoot) !== controllerWorkspace) { + throw new RepositoryError( + `Repository lease identity mismatch for ${identity.id}.`, + "run_conflict", + ); + } + assertPathWithin(poolDirectory, lease.path); + const expected = metadataFor(identity); + for (const [key, value] of Object.entries(expected)) { + if (lease.metadata?.[key] !== value) { + throw new RepositoryError( + `Repository lease metadata mismatch for ${identity.id}.`, + "run_conflict", + ); + } + } +} + +function assertTarget(target: GroveLeaseTarget | undefined, identity: RunIdentity): void { + if (!target || !matchesTarget(target, identity)) { + throw new RepositoryError( + `Repository lease target mismatch for ${identity.id}.`, + "run_conflict", + ); + } +} + +function matchesTarget(target: GroveLeaseTarget, identity: RunIdentity): boolean { + return ( + target.mode === "branch" && + target.branch === identity.branch && + target.createFromRef === identity.base.baseSha && + target.createFromSha === identity.base.baseSha + ); +} + +function matchesCleanup(lease: GroveLease, baseSha: string): boolean { + return ( + lease.pendingCleanup?.cleanup === "reset" && + lease.pendingCleanup.resetTo === baseSha && + lease.pendingCleanup.force === true && + lease.pendingCleanup.cleanIgnored !== true + ); +} + +async function assertNoActiveWorktreeProcesses(grove: Grove, leaseId: string): Promise { + const leases = await grove.list({ includeProcesses: true }); + const lease = leases.find((candidate) => candidate.leaseId === leaseId); + const safety = lease?.diagnostics?.lastProcessSafetyCheck; + if ( + !lease || + lease.processSafety !== "verified" || + safety?.status !== "verified" || + (safety.processes?.length ?? 0) > 0 + ) { + throw new RepositoryError( + `Repository run ${leaseId} has active processes or unverified cleanup safety.`, + "cleanup_failed", + ); + } +} + +function assertBase(base: RepositoryBase, remote: string): void { + if (base.remote !== remote) { + throw new RepositoryError( + "Repository base remote does not match this repository.", + "run_conflict", + ); + } + if (!/^[0-9a-f]{40,64}$/.test(base.baseSha)) { + throw new RepositoryError("Repository base SHA is invalid.", "invalid_input"); + } + if (!base.baseRef.trim()) { + throw new RepositoryError("Repository base ref must not be empty.", "invalid_input"); + } +} + +function assertAbsolutePath(name: string, value: string): string { + if (!isAbsolute(value)) { + throw new RepositoryError(`${name} must be an absolute path.`, "invalid_input"); + } + return resolve(value); +} + +function assertSeparatePaths(controllerWorkspace: string, poolDirectory: string): void { + if ( + controllerWorkspace === poolDirectory || + isWithin(controllerWorkspace, poolDirectory) || + isWithin(poolDirectory, controllerWorkspace) + ) { + throw new RepositoryError( + "Repository controller and pool directories must not overlap.", + "invalid_input", + ); + } +} + +function assertPathWithin(parent: string, child: string): void { + if (!isWithin(parent, resolve(child))) { + throw new RepositoryError("Grove returned a path outside the repository pool.", "run_conflict"); + } +} + +function isWithin(parent: string, child: string): boolean { + const path = relative(parent, child); + return path !== "" && path !== ".." && !path.startsWith(`..${sep}`); +} + +function isGroveLease(value: unknown): value is GroveLease { + return typeof value === "object" && value !== null && "leaseId" in value && "state" in value; +} + +function isReleaseResult(value: unknown): value is ReleaseResult { + return typeof value === "object" && value !== null && "status" in value && "leaseId" in value; +} + +export type { + CreateRepositoryOptions, + RepositoryBase, + RepositoryChange, + RepositoryChangeStatus, + RepositoryCleanupResult, + RepositoryRun, + RepositoryService, +} from "./types.ts"; diff --git a/lib/repository/setup.ts b/lib/repository/setup.ts new file mode 100644 index 0000000..ae13862 --- /dev/null +++ b/lib/repository/setup.ts @@ -0,0 +1,78 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { RepositoryError } from "./error.ts"; + +const execFileAsync = promisify(execFile); +const SETUP_ENVIRONMENT_KEYS = Object.freeze([ + "COREPACK_HOME", + "HOME", + "LANG", + "LC_ALL", + "LC_CTYPE", + "NODE_EXTRA_CA_CERTS", + "PATH", + "PNPM_CONFIG_STORE_DIR", + "PNPM_HOME", + "SSL_CERT_DIR", + "SSL_CERT_FILE", + "TMPDIR", + "XDG_CACHE_HOME", +] as const); + +export function repositorySetupEnvironment( + source: NodeJS.ProcessEnv, +): Readonly> { + const environment: Record = { + CI: "1", + GIT_TERMINAL_PROMPT: "0", + }; + for (const key of SETUP_ENVIRONMENT_KEYS) { + const value = source[key]; + if (value !== undefined) environment[key] = value; + } + return Object.freeze(environment); +} + +export async function runRepositorySetup(input: { + workspace: string; + command: readonly string[]; + timeoutMs: number; + environment: NodeJS.ProcessEnv; +}): Promise { + const [executable, ...args] = input.command; + if (!executable) { + throw new RepositoryError("Repository setup command must not be empty.", "invalid_input"); + } + + try { + await execFileAsync(executable, args, { + cwd: input.workspace, + env: { ...repositorySetupEnvironment(input.environment) }, + timeout: input.timeoutMs, + maxBuffer: 8 * 1024 * 1024, + }); + } catch (error) { + const diagnostic = setupDiagnostic(error); + throw new RepositoryError( + `Repository setup failed${diagnostic ? `: ${diagnostic}` : "."}`, + "setup_failed", + { cause: error }, + ); + } +} + +function setupDiagnostic(error: unknown): string { + if (!(error instanceof Error)) return String(error); + + const record = error as Error & { + killed?: boolean; + signal?: string; + stderr?: string | Buffer; + }; + if (record.killed) { + return record.signal ? `terminated by ${record.signal}` : "timed out"; + } + const stderr = String(record.stderr ?? "").trim(); + if (stderr) return stderr.slice(-4_000); + return error.message; +} diff --git a/lib/repository/types.ts b/lib/repository/types.ts new file mode 100644 index 0000000..6f0f7ed --- /dev/null +++ b/lib/repository/types.ts @@ -0,0 +1,50 @@ +import type { RepositoryRunsConfigInput } from "./config-schema.ts"; + +export type RepositoryBase = Readonly<{ + remote: string; + baseRef: string; + baseSha: string; +}>; + +export type RepositoryRun = Readonly<{ + version: 1; + id: string; + workspace: string; + remote: string; + baseRef: string; + baseSha: string; + branch: string; +}>; + +export type RepositoryChangeStatus = + | "added" + | "modified" + | "deleted" + | "renamed" + | "copied" + | "untracked" + | "conflicted"; + +export type RepositoryChange = Readonly<{ + path: string; + previousPath?: string; + status: RepositoryChangeStatus; +}>; + +export type RepositoryCleanupResult = Readonly<{ + status: "released" | "already-clean"; +}>; + +export type RepositoryService = Readonly<{ + resolveBase(input: { baseRef: string }): Promise; + prepareRun(input: { id: string; base: RepositoryBase; branch: string }): Promise; + inspectChanges(run: RepositoryRun): Promise; + cleanupRun(run: RepositoryRun): Promise; +}>; + +export type CreateRepositoryOptions = RepositoryRunsConfigInput & { + controllerWorkspace: string; + poolDirectory: string; + ownerId?: string; + setupEnvironment?: NodeJS.ProcessEnv; +}; diff --git a/package.json b/package.json index 055addb..ecd6e73 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@cursor/sdk": "1.0.23", + "@ferueda/grove": "1.4.4", "@linear/sdk": "88.2.0", "@openai/codex": "0.145.0", "@openai/codex-sdk": "0.145.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffd4547..41e737c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@cursor/sdk': specifier: 1.0.23 version: 1.0.23 + '@ferueda/grove': + specifier: 1.4.4 + version: 1.4.4 '@linear/sdk': specifier: 88.2.0 version: 88.2.0(graphql@17.0.2) @@ -131,6 +134,9 @@ packages: resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} + '@ferueda/grove@1.4.4': + resolution: {integrity: sha512-H/LbRAa5jdECjxp7Skv4XzdKzltHbGAYWzDHhVm1Uu8e1m6ksxJCxqt9yMCDFsaopql5pdHjrIF+eoKtTAA6bg==} + '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} peerDependencies: @@ -1084,6 +1090,13 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1430,6 +1443,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1450,6 +1467,10 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1481,6 +1502,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1490,6 +1515,9 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@17.0.2: resolution: {integrity: sha512-FRWbddMxfkjiB7z+aQDWIR+E34xo9I8c9mtK2RPv8PmMzKRvrdsreHL/Ui/TmwHJfhHChEtsFPyMHKI+xuarQQ==} engines: {node: ^22.0.0 || ^24.0.0 || ^25.0.0 || >=26.0.0} @@ -1505,6 +1533,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + import-in-the-middle@1.15.0: resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} @@ -1567,6 +1599,18 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1714,6 +1758,10 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -1747,10 +1795,18 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -1799,6 +1855,13 @@ packages: resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} engines: {node: '>=0.10.0'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -1820,6 +1883,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + rimraf@5.0.10: resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} hasBin: true @@ -1844,6 +1911,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -1882,6 +1952,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -1955,6 +2029,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2092,6 +2170,10 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -2167,6 +2249,12 @@ snapshots: '@fastify/busboy@2.1.1': {} + '@ferueda/grove@1.4.4': + dependencies: + execa: 9.6.1 + proper-lockfile: 4.1.2 + zod: 4.4.3 + '@graphql-typed-document-node/core@3.2.0(graphql@17.0.2)': dependencies: graphql: 17.0.2 @@ -3156,6 +3244,10 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} '@statsig/client-core@3.31.0': {} @@ -3436,6 +3528,21 @@ snapshots: dependencies: '@types/estree': 1.0.9 + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.4.0: {} extend@3.0.2: {} @@ -3449,6 +3556,10 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -3484,6 +3595,11 @@ snapshots: get-caller-file@2.0.5: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3495,6 +3611,8 @@ snapshots: google-logging-utils@1.1.3: {} + graceful-fs@4.2.11: {} + graphql@17.0.2: {} hash.js@1.1.7: @@ -3513,6 +3631,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + import-in-the-middle@1.15.0: dependencies: acorn: 8.17.0 @@ -3577,6 +3697,12 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + isexe@2.0.0: {} jackspeak@3.4.3: @@ -3688,6 +3814,11 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + obug@2.1.3: {} oxfmt@0.59.0: @@ -3738,8 +3869,12 @@ snapshots: package-json-from-dist@1.0.1: {} + parse-ms@4.0.0: {} + path-key@3.1.1: {} + path-key@4.0.0: {} + path-parse@1.0.7: {} path-scurry@1.11.1: @@ -3781,6 +3916,16 @@ snapshots: dependencies: xtend: 4.0.2 + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -3819,6 +3964,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + retry@0.12.0: {} + rimraf@5.0.10: dependencies: glob: 10.5.0 @@ -3854,6 +4001,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} simple-git-hooks@2.13.1: {} @@ -3886,6 +4035,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + supports-preserve-symlinks-flag@1.0.0: {} systeminformation@5.31.17: {} @@ -3958,6 +4109,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + unicorn-magic@0.3.0: {} + vite@8.0.16(@types/node@26.1.1)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -4048,6 +4201,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yoctocolors@2.1.2: {} + zod@3.25.76: {} zod@4.4.3: {} diff --git a/scripts/smoke-linear-automation-compose.ts b/scripts/smoke-linear-automation-compose.ts index 273c2a3..2d62217 100644 --- a/scripts/smoke-linear-automation-compose.ts +++ b/scripts/smoke-linear-automation-compose.ts @@ -14,6 +14,16 @@ const SMOKE_OVERRIDE = join(ROOT, "compose.linear-automation.smoke.yaml"); const POLL_EVENT_NAME = "linear/poll.requested"; const EVENT_KEY = "0123456789abcdef0123456789abcdef"; const SIGNING_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +const REPOSITORY_SETUP_SCRIPT = [ + 'const fs = require("node:fs");', + 'const path = require("node:path");', + 'const target = path.join(process.cwd(), "node_modules/.compose-setup.json");', + "fs.mkdirSync(path.dirname(target), { recursive: true });", + "let previous = { calls: 0 };", + 'try { previous = JSON.parse(fs.readFileSync(target, "utf8")); } catch {}', + "const forbidden = Object.keys(process.env).filter((key) => /^(?:LINEAR|INNGEST|GITHUB|CODEX)_/.test(key));", + "fs.writeFileSync(target, JSON.stringify({ calls: previous.calls + 1, forbidden }));", +].join("\n"); const fixtureRoot = mkdtempSync(join(tmpdir(), "harness-linear-compose-smoke-")); const workspace = join(fixtureRoot, "workspace"); const environmentFile = join(fixtureRoot, "compose.env"); @@ -169,6 +179,10 @@ function getEventRuns(eventId: string): EventRun[] { function prepareFixture(dashboardPort: number): void { run("git", ["init", "--quiet", "--initial-branch", "main", workspace], 30_000); + run("git", ["-C", workspace, "config", "user.email", "harness@example.com"], 30_000); + run("git", ["-C", workspace, "config", "user.name", "Harness Smoke"], 30_000); + writeFileSync(join(workspace, ".gitignore"), "node_modules/\n", "utf8"); + writeFileSync(join(workspace, "README.md"), "# Compose repository fixture\n", "utf8"); writeFileSync( join(workspace, "harness.json"), `${JSON.stringify( @@ -206,6 +220,8 @@ function prepareFixture(dashboardPort: number): void { )}\n`, "utf8", ); + run("git", ["-C", workspace, "add", "."], 30_000); + run("git", ["-C", workspace, "commit", "--quiet", "-m", "Initialize fixture"], 30_000); writeFileSync( environmentFile, [ @@ -285,17 +301,86 @@ try { 'import { constants } from "node:fs";', 'if (process.getuid?.() === 0) throw new Error("worker runs as root");', "await access(process.env.CODEX_HOME, constants.W_OK);", + "await access(process.env.HARNESS_REPOSITORY_ROOT, constants.W_OK);", + "await access(process.env.PNPM_CONFIG_STORE_DIR.replace(/\\/store$/, ''), constants.W_OK);", 'execFileSync("codex", ["--version"], { stdio: "inherit" });', + 'execFileSync("pnpm", ["--version"], { stdio: "inherit" });', ].join("\n"); compose( ["exec", "--no-TTY", "worker", "node", "--input-type=module", "--eval", runtimeProbe], 30_000, ); + station = "repository run before restart"; + const prepareRepositoryProbe = [ + 'import { mkdir, writeFile } from "node:fs/promises";', + 'import { join } from "node:path";', + 'import { createRepository } from "./dist/lib/repository/repository.js";', + 'const root = join(process.env.HARNESS_REPOSITORY_ROOT, "compose-smoke");', + "const repository = createRepository({", + ' remote: "/workspace",', + ' controllerWorkspace: join(root, "controller"),', + ' poolDirectory: join(root, "grove"),', + " maxTrees: 2,", + ` setup: { command: [process.execPath, "--eval", ${JSON.stringify(REPOSITORY_SETUP_SCRIPT)}], timeoutMs: 30_000 },`, + " setupEnvironment: process.env,", + "});", + 'const base = await repository.resolveBase({ baseRef: "main" });', + 'const run = await repository.prepareRun({ id: "compose-smoke-run", base, branch: "codex/compose-smoke" });', + 'await writeFile(join(run.workspace, "agent-output.txt"), "durable work\\n");', + 'await mkdir(join(run.workspace, "node_modules"), { recursive: true });', + 'await writeFile(join(run.workspace, "node_modules/.warm-marker"), "warm\\n");', + 'await writeFile(join(root, "run.json"), JSON.stringify(run));', + ].join("\n"); + compose( + ["exec", "--no-TTY", "worker", "node", "--input-type=module", "--eval", prepareRepositoryProbe], + 60_000, + ); + station = "worker restart"; compose(["restart", "worker"], 180_000); await waitForHealthy("worker"); + station = "repository run recovery and warm reuse"; + const recoverRepositoryProbe = [ + 'import { access, readFile, writeFile } from "node:fs/promises";', + 'import { join } from "node:path";', + 'import { createRepository } from "./dist/lib/repository/repository.js";', + 'const root = join(process.env.HARNESS_REPOSITORY_ROOT, "compose-smoke");', + 'const original = JSON.parse(await readFile(join(root, "run.json"), "utf8"));', + "const repository = createRepository({", + ' remote: "/workspace",', + ' controllerWorkspace: join(root, "controller"),', + ' poolDirectory: join(root, "grove"),', + " maxTrees: 2,", + ` setup: { command: [process.execPath, "--eval", ${JSON.stringify(REPOSITORY_SETUP_SCRIPT)}], timeoutMs: 30_000 },`, + " setupEnvironment: process.env,", + "});", + "const base = { remote: original.remote, baseRef: original.baseRef, baseSha: original.baseSha };", + "const resumed = await repository.prepareRun({ id: original.id, base, branch: original.branch });", + 'if (resumed.workspace !== original.workspace) throw new Error("repository path changed after restart");', + 'await access(join(resumed.workspace, "agent-output.txt"));', + 'await access(join(resumed.workspace, "node_modules/.warm-marker"));', + 'const setupAfterResume = JSON.parse(await readFile(join(resumed.workspace, "node_modules/.compose-setup.json"), "utf8"));', + 'if (setupAfterResume.calls !== 2 || setupAfterResume.forbidden.length !== 0) throw new Error("repository setup did not rerun safely");', + "const changes = await repository.inspectChanges(resumed);", + 'if (!changes.some((change) => change.path === "agent-output.txt" && change.status === "untracked")) throw new Error("repository changes were not inspected");', + "await repository.cleanupRun(resumed);", + 'const reused = await repository.prepareRun({ id: "compose-smoke-reused", base, branch: "codex/compose-smoke-reused" });', + 'if (reused.workspace !== resumed.workspace) throw new Error("warm Grove slot was not reused");', + 'await access(join(reused.workspace, "node_modules/.warm-marker"));', + 'try { await access(join(reused.workspace, "agent-output.txt")); throw new Error("agent output survived reset"); } catch (error) { if (error?.message === "agent output survived reset") throw error; }', + 'const setupAfterReuse = JSON.parse(await readFile(join(reused.workspace, "node_modules/.compose-setup.json"), "utf8"));', + 'if (setupAfterReuse.calls !== 3) throw new Error("warm setup count was not preserved");', + "await repository.cleanupRun(reused);", + 'await writeFile(join(process.env.PNPM_CONFIG_STORE_DIR.replace(/\\/store$/, ""), ".compose-cache-marker"), "cached\\n");', + 'try { await access("/workspace/agent-output.txt"); throw new Error("read-only source was mutated"); } catch (error) { if (error?.message === "read-only source was mutated") throw error; }', + ].join("\n"); + compose( + ["exec", "--no-TTY", "worker", "node", "--input-type=module", "--eval", recoverRepositoryProbe], + 120_000, + ); + station = "durable event acceptance"; const eventId = sendPollEvent(`linear-compose-smoke-${process.pid}`); let runs: EventRun[] = []; @@ -321,6 +406,14 @@ try { preservedVolumes.some((volume) => volume.endsWith("_codex-home")), "Codex volume was not preserved by normal shutdown", ); + assert( + preservedVolumes.some((volume) => volume.endsWith("_repository-data")), + "repository data volume was not preserved by normal shutdown", + ); + assert( + preservedVolumes.some((volume) => volume.endsWith("_package-manager-cache")), + "package manager cache volume was not preserved by normal shutdown", + ); cleanup(); console.log( diff --git a/test/config.test.ts b/test/config.test.ts index 590702b..ef18ef1 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -150,6 +150,36 @@ test("resolveHarnessOptions applies provider model defaults", () => { expect(codexOptions.modelReasoningEffort).toBe("high"); }); +test("harness config validates repository run setup and applies the small pool default", () => { + const workspace = mkdtempSync(join(tmpdir(), "harness-repository-config-")); + writeHarnessJson(workspace, { + repositoryRuns: { + remote: "https://github.com/example/project.git", + setup: { + command: ["pnpm", "install", "--frozen-lockfile"], + timeoutMs: 600_000, + }, + }, + }); + + expect(loadHarnessConfigSnapshot(workspace, "/").config.repositoryRuns).toEqual({ + remote: "https://github.com/example/project.git", + maxTrees: 2, + setup: { + command: ["pnpm", "install", "--frozen-lockfile"], + timeoutMs: 600_000, + }, + }); + + writeHarnessJson(workspace, { + repositoryRuns: { + remote: "https://github.com/example/project.git", + setup: { command: [], timeoutMs: 600_000 }, + }, + }); + expect(() => loadHarnessConfigSnapshot(workspace, "/")).toThrow(/repositoryRuns\.setup\.command/); +}); + test("resolveLinearAutomationSettings resolves one immutable worker snapshot", () => { const workspace = mkdtempSync(join(tmpdir(), "harness-linear-automation-")); writeHarnessJson(workspace, { diff --git a/test/docs-contracts.test.ts b/test/docs-contracts.test.ts index e57e7d4..220f538 100644 --- a/test/docs-contracts.test.ts +++ b/test/docs-contracts.test.ts @@ -618,19 +618,12 @@ test("readme stays a concise entrypoint", () => { expect(readme).not.toMatch(/^### [a-z0-9]+(?:-[a-z0-9]+)+$/m); }); -test("retired Factory docs, commands, skill, and dependency stay absent", () => { +test("retired Factory docs, commands, and skill stay absent", () => { expect(existsSync(join(REPO_ROOT, "docs/contributing/factory.md"))).toBe(false); expect(existsSync(join(REPO_ROOT, "skills/factory-operator/SKILL.md"))).toBe(false); expect(existsSync(join(REPO_ROOT, "scripts/smoke-factory.ts"))).toBe(false); expect(existsSync(join(REPO_ROOT, "scripts/smoke-factory-grove.ts"))).toBe(false); - const packageJson = readRepoFile("package.json"); - const workspace = readRepoFile("pnpm-workspace.yaml"); - const lockfile = readRepoFile("pnpm-lock.yaml"); - for (const content of [packageJson, workspace, lockfile]) { - expect(content).not.toContain("@ferueda/grove"); - } - for (const path of durableDocPaths()) { expect(readRepoFile(path), `${path} still documents Factory`).not.toMatch(/\bfactory\b/i); } diff --git a/test/import-boundaries.test.ts b/test/import-boundaries.test.ts index c5b2186..f5c3fcf 100644 --- a/test/import-boundaries.test.ts +++ b/test/import-boundaries.test.ts @@ -160,7 +160,12 @@ describe("automation import boundaries", () => { expectBoundaryViolation( "lib/repository/forbidden.ts", 'import type { LinearService } from "../linear/client.ts";', - "not tracker or domain policy", + "not tracker, publication, provider, or domain policy", + ); + expectBoundaryViolation( + "lib/repository/forbidden.ts", + 'import { publishRun } from "../github/publication.ts";', + "not tracker, publication, provider, or domain policy", ); }); From 45625ff3f58563b0addff77f0297af675efe220c Mon Sep 17 00:00:00 2001 From: ferueda Date: Thu, 23 Jul 2026 12:21:18 -0700 Subject: [PATCH 2/3] fix: harden repository run safeguards --- lib/repository/git.ts | 35 ++++++++------ lib/repository/repository.test.ts | 53 +++++++++++++++++++++- scripts/smoke-linear-automation-compose.ts | 9 +++- 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/lib/repository/git.ts b/lib/repository/git.ts index 4ae8644..e77938d 100644 --- a/lib/repository/git.ts +++ b/lib/repository/git.ts @@ -3,7 +3,7 @@ import { existsSync } from "node:fs"; import { mkdir, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; -import { RepositoryError } from "./error.ts"; +import { RepositoryError, type RepositoryErrorCode } from "./error.ts"; import type { RepositoryChange, RepositoryChangeStatus } from "./types.ts"; const execFileAsync = promisify(execFile); @@ -106,17 +106,19 @@ export async function resolveRemoteBase(input: { } export async function inspectGitChanges(workspace: string): Promise { - const output = await runGit(workspace, [ - "status", - "--porcelain=v1", - "-z", - "--untracked-files=all", - "--ignore-submodules=none", - ]); + const output = await runGit( + workspace, + ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"], + "inspect_failed", + ); return parsePorcelain(output); } -export async function runGit(workspace: string, args: readonly string[]): Promise { +export async function runGit( + workspace: string, + args: readonly string[], + errorCode: RepositoryErrorCode = "controller_failed", +): Promise { try { const { stdout } = await execFileAsync("git", [...args], { cwd: workspace, @@ -130,7 +132,7 @@ export async function runGit(workspace: string, args: readonly string[]): Promis const stderr = String(record.stderr ?? "").trim(); throw new RepositoryError( stderr || (error instanceof Error ? error.message : String(error)), - "controller_failed", + errorCode, { cause: error }, ); } @@ -141,9 +143,16 @@ function baseCandidates(baseRef: string): readonly string[] { if (!ref) { throw new RepositoryError("Repository base ref must not be empty.", "invalid_input"); } - if (ref.startsWith("refs/") || FULL_GIT_SHA.test(ref)) return [ref]; - if (ref.startsWith("origin/")) return [`refs/remotes/${ref}`, ref]; - return [`refs/remotes/origin/${ref}`, ref]; + if (FULL_GIT_SHA.test(ref)) return [ref]; + if (ref.startsWith("refs/remotes/origin/")) return [ref]; + if (ref.startsWith("origin/")) return [`refs/remotes/${ref}`]; + if (ref.startsWith("refs/")) { + throw new RepositoryError( + "Repository base must be a remote origin branch or an exact commit SHA.", + "invalid_input", + ); + } + return [`refs/remotes/origin/${ref}`]; } function parsePorcelain(output: string): readonly RepositoryChange[] { diff --git a/lib/repository/repository.test.ts b/lib/repository/repository.test.ts index 7ddde84..ef0b49c 100644 --- a/lib/repository/repository.test.ts +++ b/lib/repository/repository.test.ts @@ -5,7 +5,8 @@ import { transitionSlot, writeLeaseFirstState, } from "@ferueda/grove"; -import { execFileSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; +import { once } from "node:events"; import { existsSync, mkdtempSync, @@ -108,6 +109,15 @@ test("repository runs resolve an exact base, reacquire dirty work, and inspect p expect(git(run.workspace, ["rev-parse", "HEAD"])).toBe(base.baseSha); const newerBase = await repository.resolveBase({ baseRef: "main" }); expect(newerBase.baseSha).not.toBe(base.baseSha); + + git(fixture.source, ["branch", "stale", "main"]); + git(fixture.source, ["push", "origin", "stale"]); + await repository.resolveBase({ baseRef: "stale" }); + git(fixture.controller, ["branch", "stale", "refs/remotes/origin/stale"]); + git(fixture.source, ["push", "origin", "--delete", "stale"]); + await expect(repository.resolveBase({ baseRef: "stale" })).rejects.toMatchObject({ + code: "controller_failed", + }); }); test("repository cleanup reuses a bounded warm pool while preserving ignored dependencies", async () => { @@ -136,6 +146,26 @@ test("repository cleanup reuses a bounded warm pool while preserving ignored dep writeFileSync(join(first.workspace, "README.md"), "# disposable\n", "utf8"); writeFileSync(join(first.workspace, "agent-output.txt"), "remove me\n", "utf8"); + const active = spawn( + process.execPath, + ["-e", 'process.stdout.write("ready"); setInterval(() => {}, 1_000);'], + { + cwd: first.workspace, + stdio: ["ignore", "pipe", "inherit"], + }, + ); + await once(active.stdout, "data"); + try { + await expect(repository.cleanupRun(first)).rejects.toMatchObject({ + code: "cleanup_failed", + }); + expect(readFileSync(join(first.workspace, "agent-output.txt"), "utf8")).toBe("remove me\n"); + } finally { + const activeExit = once(active, "exit"); + active.kill("SIGTERM"); + await activeExit; + } + expect(await repository.cleanupRun(first)).toEqual({ status: "released" }); expect(await repository.cleanupRun(first)).toEqual({ status: "already-clean" }); @@ -312,6 +342,27 @@ test("repository rejects credential-bearing remotes and overlapping storage", as ).toThrow(RepositoryError); }); +test("failed Git inspection reports the inspection boundary", async () => { + const fixture = createFixture(); + const repository = createTestRepository(fixture); + const base = await repository.resolveBase({ baseRef: "main" }); + const run = await repository.prepareRun({ + id: "run-inspection-error", + base, + branch: "codex/inspection-error", + }); + const unavailable = `${run.workspace}-unavailable`; + renameSync(run.workspace, unavailable); + try { + await expect(repository.inspectChanges(run)).rejects.toMatchObject({ + code: "inspect_failed", + }); + } finally { + renameSync(unavailable, run.workspace); + } + expect(await repository.cleanupRun(run)).toEqual({ status: "released" }); +}); + type Fixture = Readonly<{ root: string; remote: string; diff --git a/scripts/smoke-linear-automation-compose.ts b/scripts/smoke-linear-automation-compose.ts index 2d62217..e84bdd1 100644 --- a/scripts/smoke-linear-automation-compose.ts +++ b/scripts/smoke-linear-automation-compose.ts @@ -348,6 +348,11 @@ try { 'import { createRepository } from "./dist/lib/repository/repository.js";', 'const root = join(process.env.HARNESS_REPOSITORY_ROOT, "compose-smoke");', 'const original = JSON.parse(await readFile(join(root, "run.json"), "utf8"));', + "async function assertMissing(path) {", + " try { await access(path); }", + ' catch (error) { if (error?.code === "ENOENT") return; throw error; }', + " throw new Error(`expected missing path: ${path}`);", + "}", "const repository = createRepository({", ' remote: "/workspace",', ' controllerWorkspace: join(root, "controller"),', @@ -369,12 +374,12 @@ try { 'const reused = await repository.prepareRun({ id: "compose-smoke-reused", base, branch: "codex/compose-smoke-reused" });', 'if (reused.workspace !== resumed.workspace) throw new Error("warm Grove slot was not reused");', 'await access(join(reused.workspace, "node_modules/.warm-marker"));', - 'try { await access(join(reused.workspace, "agent-output.txt")); throw new Error("agent output survived reset"); } catch (error) { if (error?.message === "agent output survived reset") throw error; }', + 'await assertMissing(join(reused.workspace, "agent-output.txt"));', 'const setupAfterReuse = JSON.parse(await readFile(join(reused.workspace, "node_modules/.compose-setup.json"), "utf8"));', 'if (setupAfterReuse.calls !== 3) throw new Error("warm setup count was not preserved");', "await repository.cleanupRun(reused);", 'await writeFile(join(process.env.PNPM_CONFIG_STORE_DIR.replace(/\\/store$/, ""), ".compose-cache-marker"), "cached\\n");', - 'try { await access("/workspace/agent-output.txt"); throw new Error("read-only source was mutated"); } catch (error) { if (error?.message === "read-only source was mutated") throw error; }', + 'await assertMissing("/workspace/agent-output.txt");', ].join("\n"); compose( ["exec", "--no-TTY", "worker", "node", "--input-type=module", "--eval", recoverRepositoryProbe], From 50ae3343efb1f8cdeb3ec9cee8feb9f0cd6112c5 Mon Sep 17 00:00:00 2001 From: ferueda Date: Thu, 23 Jul 2026 12:32:01 -0700 Subject: [PATCH 3/3] fix: validate repository base refs --- lib/repository/git.ts | 3 +++ lib/repository/repository.test.ts | 23 ++++++++++++++++++++--- lib/repository/repository.ts | 5 +---- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/lib/repository/git.ts b/lib/repository/git.ts index e77938d..48cda4e 100644 --- a/lib/repository/git.ts +++ b/lib/repository/git.ts @@ -88,6 +88,9 @@ export async function resolveRemoteBase(input: { const candidates = baseCandidates(input.baseRef); for (const candidate of candidates) { + if (!FULL_GIT_SHA.test(candidate)) { + await runGit(input.controllerWorkspace, ["check-ref-format", candidate], "invalid_input"); + } try { const sha = await runGit(input.controllerWorkspace, [ "rev-parse", diff --git a/lib/repository/repository.test.ts b/lib/repository/repository.test.ts index ef0b49c..2d357e6 100644 --- a/lib/repository/repository.test.ts +++ b/lib/repository/repository.test.ts @@ -49,6 +49,23 @@ test("repository runs resolve an exact base, reacquire dirty work, and inspect p const fixture = createFixture(); const repository = createTestRepository(fixture); const base = await repository.resolveBase({ baseRef: "main" }); + await expect(repository.resolveBase({ baseRef: "origin/main" })).resolves.toEqual({ + ...base, + baseRef: "origin/main", + }); + await expect(repository.resolveBase({ baseRef: "refs/remotes/origin/main" })).resolves.toEqual({ + ...base, + baseRef: "refs/remotes/origin/main", + }); + await expect(repository.resolveBase({ baseRef: base.baseSha })).resolves.toEqual({ + ...base, + baseRef: base.baseSha, + }); + for (const baseRef of ["main~1", "origin/main^", "refs/remotes/origin/main~1"]) { + await expect(repository.resolveBase({ baseRef })).rejects.toMatchObject({ + code: "invalid_input", + }); + } const run = await repository.prepareRun({ id: "run-inspect", base, @@ -154,15 +171,15 @@ test("repository cleanup reuses a bounded warm pool while preserving ignored dep stdio: ["ignore", "pipe", "inherit"], }, ); - await once(active.stdout, "data"); + const activeExit = once(active, "exit"); try { + await once(active.stdout, "data"); await expect(repository.cleanupRun(first)).rejects.toMatchObject({ code: "cleanup_failed", }); expect(readFileSync(join(first.workspace, "agent-output.txt"), "utf8")).toBe("remove me\n"); } finally { - const activeExit = once(active, "exit"); - active.kill("SIGTERM"); + if (active.exitCode === null) active.kill("SIGTERM"); await activeExit; } diff --git a/lib/repository/repository.ts b/lib/repository/repository.ts index 86b7341..afaa455 100644 --- a/lib/repository/repository.ts +++ b/lib/repository/repository.ts @@ -1,5 +1,6 @@ import { createGrove, + isReleaseResult, type Grove, type GroveLease, type GroveLeaseTarget, @@ -401,10 +402,6 @@ function isGroveLease(value: unknown): value is GroveLease { return typeof value === "object" && value !== null && "leaseId" in value && "state" in value; } -function isReleaseResult(value: unknown): value is ReleaseResult { - return typeof value === "object" && value !== null && "status" in value && "leaseId" in value; -} - export type { CreateRepositoryOptions, RepositoryBase,