mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# Cursor Cloud Agents — parallel workflow
|
||||
|
||||
Operator playbook for running 15–20 [Cursor Cloud Agents](https://docs.cursor.com/agents/cloud) in parallel against OpenHuman. Companion to [`codex-pr-checklist.md`](codex-pr-checklist.md); the same merge gates apply.
|
||||
|
||||
This doc closes [`tinyhumansai/openhuman#1480`](https://github.com/tinyhumansai/openhuman/issues/1480).
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. Write a **batch spec** — one JSON file naming N agents, their issues, branches, and owned paths.
|
||||
2. **Validate** it (`pnpm agent-batch validate <spec>`) and **prove ownership disjointness** (`pnpm agent-batch overlap <spec>`).
|
||||
3. Post **one launch comment** per agent (generated from the spec) into Cursor; each agent opens a branch and PR matching the spec.
|
||||
4. Track progress with `pnpm agent-batch status <spec>` — markdown table of PR + CI per agent.
|
||||
5. Pilot at N=3 before scaling to 15–20.
|
||||
|
||||
Concretely, none of this is "Cursor magic" — it is a JSON contract + three small scripts that fail loudly if humans break it.
|
||||
|
||||
## Why a contract
|
||||
|
||||
Running N agents in parallel breaks in three ways:
|
||||
|
||||
- **Branch / PR collisions** — two agents picking the same branch name, or opening duplicate PRs against the same issue.
|
||||
- **File collisions** — two agents editing the same module, producing conflicting merges.
|
||||
- **Quality drift** — agents skipping format / typecheck / coverage and pushing red PRs.
|
||||
|
||||
The batch spec is the single source of truth that prevents the first two. The third is enforced by upstream CI ([`.github/workflows/coverage.yml`](../../.github/workflows/coverage.yml), [`.github/workflows/pr-quality.yml`](../../.github/workflows/pr-quality.yml), [`.github/workflows/test.yml`](../../.github/workflows/test.yml)) — agents do not get to opt out.
|
||||
|
||||
## Batch spec
|
||||
|
||||
A batch is a JSON file living under `docs/agent-workflows/batches/` (gitignored — see [Privacy](#secrets-posture)) or generated ad hoc. Shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"batch_id": "pilot-2026-05-15",
|
||||
"base_repo": "tinyhumansai/openhuman",
|
||||
"base_branch": "main",
|
||||
"tracking_issue": 1480,
|
||||
"agents": [
|
||||
{
|
||||
"id": "a01",
|
||||
"issue": 1234,
|
||||
"title": "short slug for the branch name",
|
||||
"branch": "cursor/a01-1234-short-slug",
|
||||
"owned_paths": ["app/src/features/foo/", "src/openhuman/foo/"],
|
||||
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
|
||||
"labels": ["cursor-agent", "pilot"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field rules:
|
||||
|
||||
| Field | Required | Notes |
|
||||
| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `batch_id` | yes | Stable identifier — appears in PR bodies and the tracking comment. |
|
||||
| `base_repo` | yes | Always `tinyhumansai/openhuman` unless explicitly delegated. |
|
||||
| `base_branch` | yes | `main`. |
|
||||
| `tracking_issue` | yes | One upstream issue per batch; that issue's comment thread is the dashboard (AC #6). |
|
||||
| `agents[].id` | yes | Two-char + digits, e.g. `a01`–`a20`. Unique within batch. |
|
||||
| `agents[].issue` | yes | Upstream issue number. **One issue per agent**, **one agent per issue.** |
|
||||
| `agents[].branch` | yes | Must start with `cursor/` and contain the agent id and issue number. |
|
||||
| `agents[].owned_paths` | yes | **Path prefixes** (directory ending in `/`) or exact files. **No globs.** Disjoint across agents — `overlap` enforces this. |
|
||||
| `agents[].allowed_shared_paths` | no | Files the agent may touch even if another agent's prefix contains them (e.g. `docs/TEST-COVERAGE-MATRIX.md`, capability catalog). Best-effort only — overlap on these is **warned**, not blocked. |
|
||||
| `agents[].labels` | no | PR labels. Always include `cursor-agent`. Add `docs` or `chore` to opt out of the soft `pr-quality` checks per [`pr-quality.yml`](../../.github/workflows/pr-quality.yml). |
|
||||
|
||||
### Why prefixes, not globs
|
||||
|
||||
A glob ownership model (`app/src/components/**`) is tempting but makes overlap detection ambiguous: does `app/src/**/*.test.ts` collide with `app/src/components/Foo/`? With prefixes the answer is mechanical: prefix containment in either direction = collision. CI files like `.github/workflows/*.yml`, the capability catalog at `src/openhuman/about_app/`, and similar shared surfaces should be assigned to **one** agent for the batch (or to no agent — the batch should be designed not to need them).
|
||||
|
||||
## Branch & PR conventions
|
||||
|
||||
- Branch off **`origin/main` (upstream)** at the moment the spec is written. Each agent fetches `origin/main` itself.
|
||||
- Branch name format: `cursor/<id>-<issue>-<short-slug>` (e.g. `cursor/a04-1456-memory-namespace`). Enforced by `validate.mjs`.
|
||||
- Push to the **forking remote the Cursor workspace is configured with**, not directly to `tinyhumansai/openhuman`. PRs are opened with `--head <fork-owner>:<branch>` against `tinyhumansai/openhuman:main`.
|
||||
- **One PR per issue**, **one PR per branch**. If a retry is needed, update the existing PR; do not open a duplicate. Use the duplicate cleanup recipe in [`codex-pr-checklist.md`](codex-pr-checklist.md#duplicate-pr-cleanup).
|
||||
- PR title: `<area>: <short imperative> (#<issue>)`. PR body **must** follow [`.github/PULL_REQUEST_TEMPLATE.md`](../../.github/PULL_REQUEST_TEMPLATE.md) verbatim, including the `## AI Authored PR Metadata` section.
|
||||
- PR labels include at minimum `cursor-agent` and the batch id label `batch:<batch_id>` so the tracking comment can find them.
|
||||
|
||||
## Ownership boundaries
|
||||
|
||||
Disjointness rule: for any two agents A and B in the batch, no path prefix in `A.owned_paths` may be a prefix of any path in `B.owned_paths`, in either direction. `scripts/agent-batch/overlap.mjs` checks this and exits non-zero on a collision.
|
||||
|
||||
The rule applies to **prefixes**, not file existence. Two agents may own paths that don't exist yet (new modules), as long as no prefix contains another.
|
||||
|
||||
If two issues genuinely need the same module, **do not split them across agents**. Combine them into a single agent's scope or sequence the work.
|
||||
|
||||
## Quality gates
|
||||
|
||||
Agents run the same gates as any other PR. The launch comment instructs them explicitly — they do not get to drop any of these:
|
||||
|
||||
- **Format**: `pnpm --filter openhuman-app format:check`, `cargo fmt --manifest-path Cargo.toml --all --check`, and the Tauri shell equivalent if shell files changed.
|
||||
- **Lint / typecheck**: `pnpm lint`, `pnpm typecheck`.
|
||||
- **Tests (focused)**: targeted Vitest for changed files, focused Rust tests via `pnpm debug rust <filter>` for changed Rust.
|
||||
- **Coverage**: agents must run `pnpm test:coverage` and `pnpm test:rust` locally and add tests for changed lines. The merge gate is `≥ 80% diff coverage`, enforced server-side by [`coverage.yml`](../../.github/workflows/coverage.yml). PRs below the threshold do not merge — agents that cannot reach the threshold must say so in the PR body, not paper over it.
|
||||
- **PR checklist + coverage matrix**: [`pr-quality.yml`](../../.github/workflows/pr-quality.yml) checks the PR body and `docs/TEST-COVERAGE-MATRIX.md`. The `docs` and `chore` labels exempt a PR from these soft gates — use them only for PRs that genuinely change no behavior.
|
||||
|
||||
If the agent's environment cannot run a gate, the PR body must report the **exact command and error** under `### Validation Blocked`, not claim it passed. This is the same rule as the codex checklist.
|
||||
|
||||
## Secrets posture
|
||||
|
||||
Cursor Cloud Agents inherit env from the workspace. For OpenHuman, the cloud workspace MUST be configured with:
|
||||
|
||||
- **No** `STAGING_*` / `PRODUCTION_*` secrets.
|
||||
- **No** `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or any other LLM provider key used by the production agent runtime — agents do code work, not LLM calls into production providers.
|
||||
- A scoped `GITHUB_TOKEN` with `contents:write` and `pull_requests:write` on the **fork** the workspace pushes to, plus `pull_requests:write` on `tinyhumansai/openhuman` for PR creation. **No `admin:*`, no `actions:write`, no `secrets:*`.**
|
||||
- `OPENHUMAN_APP_ENV` MUST be unset or set to `dev`. Never `staging` or `production` — staging writes `~/.openhuman-staging/core.token` referenced by [`AGENTS.md`](../../AGENTS.md) "Cursor Cloud specific instructions" and that token is **per-developer**, not for shared cloud workspaces.
|
||||
- `.env.local`, `app/.env.local`, and `core.token` files are gitignored and must not be committed.
|
||||
|
||||
The agent's own environment is the smallest blast-radius surface. Production credentials are out of scope for code-writing agents.
|
||||
|
||||
## Progress visibility
|
||||
|
||||
One tracking issue per batch (`tracking_issue` in the spec). The launch script posts a **single comment** on that issue containing a markdown table generated by `scripts/agent-batch/status.mjs`:
|
||||
|
||||
| Agent | Issue | Branch | PR | CI | Coverage | Status |
|
||||
| ----- | ----- | ------------------- | ------- | --------- | -------- | ------------ |
|
||||
| a01 | #1234 | `cursor/a01-1234-…` | `#1234` | ✓ green | 87% | merged |
|
||||
| a02 | #1235 | `cursor/a02-1235-…` | `#1235` | × failing | — | needs review |
|
||||
|
||||
Re-running `status` rewrites the same comment (looked up by a `<!-- batch:<id> -->` marker) so the issue thread doesn't fill with stale tables. The script reads:
|
||||
|
||||
- `gh pr list --repo tinyhumansai/openhuman --label batch:<id> --json …` for PR + state.
|
||||
- `gh pr checks <pr>` for CI rollup.
|
||||
- The `diff-coverage.md` artifact from `coverage.yml`, if downloaded — otherwise coverage shows `—`.
|
||||
|
||||
No external dashboard — GitHub issues + labels + the table are the single pane of glass.
|
||||
|
||||
## Pilot-then-scale
|
||||
|
||||
Do not launch 15–20 agents on day one.
|
||||
|
||||
1. **N=3 pilot.** Pick three issues that are small (~200 LOC each), in three distinct domains, no overlap. Run the full flow: spec → validate → overlap → launch → status → merge.
|
||||
2. **N=5 expansion.** Once the N=3 pilot has 3 green PRs (merged or at-review), expand to 5 in one batch. Watch CI queue times and rate limits.
|
||||
3. **N=15–20 production.** Only after two clean expansion batches. At this scale, watch `gh api rate_limit`, GitHub Actions concurrency, and Cursor's per-workspace agent limit.
|
||||
|
||||
If a batch surfaces a class of failure (e.g. agents inventing API names, agents skipping `cargo fmt`), fix the **launch comment template** that all agents inherit — don't fix it case-by-case.
|
||||
|
||||
## Operator quickstart
|
||||
|
||||
```bash
|
||||
# 1. Draft the spec
|
||||
cp docs/agent-workflows/pilot-batch-example.json /tmp/my-batch.json
|
||||
$EDITOR /tmp/my-batch.json
|
||||
|
||||
# 2. Validate shape + naming
|
||||
pnpm agent-batch validate /tmp/my-batch.json
|
||||
|
||||
# 3. Prove ownership disjointness
|
||||
pnpm agent-batch overlap /tmp/my-batch.json
|
||||
|
||||
# 4. Generate one launch comment per agent (paste into Cursor)
|
||||
pnpm agent-batch launch /tmp/my-batch.json --print-only
|
||||
|
||||
# 5. After agents have pushed, refresh the tracking comment
|
||||
pnpm agent-batch status /tmp/my-batch.json --post
|
||||
```
|
||||
|
||||
All scripts are in [`scripts/agent-batch/`](../../scripts/agent-batch/). They are zero-dep Node, executable from the repo root, and exit non-zero on policy violations so they are CI-friendly.
|
||||
|
||||
## Reference
|
||||
|
||||
- [`pilot-batch-example.json`](pilot-batch-example.json) — canonical example with 3 disjoint agents.
|
||||
- [`scripts/agent-batch/`](../../scripts/agent-batch/) — validate / overlap / launch / status implementations + `node:test` suites.
|
||||
- [`codex-pr-checklist.md`](codex-pr-checklist.md) — parent checklist; per-agent rules inherit from it.
|
||||
- [`AGENTS.md`](../../AGENTS.md) and [`CLAUDE.md`](../../CLAUDE.md) — repo-wide rules every agent must follow.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"batch_id": "example-pilot-2026-05-15",
|
||||
"base_repo": "tinyhumansai/openhuman",
|
||||
"base_branch": "main",
|
||||
"tracking_issue": 1480,
|
||||
"agents": [
|
||||
{
|
||||
"id": "a01",
|
||||
"issue": 9001,
|
||||
"title": "tighten memory namespace migration logging",
|
||||
"branch": "cursor/a01-9001-memory-namespace-logging",
|
||||
"owned_paths": ["src/openhuman/memory/"],
|
||||
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
|
||||
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
|
||||
},
|
||||
{
|
||||
"id": "a02",
|
||||
"issue": 9002,
|
||||
"title": "deduplicate cron RPC validation helpers",
|
||||
"branch": "cursor/a02-9002-cron-rpc-dedupe",
|
||||
"owned_paths": ["src/openhuman/cron/"],
|
||||
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
|
||||
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
|
||||
},
|
||||
{
|
||||
"id": "a03",
|
||||
"issue": 9003,
|
||||
"title": "settings panel a11y labels",
|
||||
"branch": "cursor/a03-9003-settings-a11y-labels",
|
||||
"owned_paths": ["app/src/components/settings/"],
|
||||
"allowed_shared_paths": ["docs/TEST-COVERAGE-MATRIX.md"],
|
||||
"labels": ["cursor-agent", "pilot", "batch:example-pilot-2026-05-15"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,8 @@
|
||||
"reset": "bash scripts/shortcuts/ws-reset.sh",
|
||||
"review": "bash scripts/shortcuts/review/cli.sh",
|
||||
"work": "bash scripts/shortcuts/work/cli.sh",
|
||||
"agent-batch": "node scripts/agent-batch/cli.mjs",
|
||||
"agent-batch:test": "node --test scripts/agent-batch/__tests__/lib.test.mjs scripts/agent-batch/__tests__/cli.test.mjs",
|
||||
"debug": "bash scripts/debug/cli.sh",
|
||||
"test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1",
|
||||
"rust:check": "pnpm --filter openhuman-app rust:check",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, ".."); // scripts/agent-batch
|
||||
const VALIDATE = join(ROOT, "validate.mjs");
|
||||
const OVERLAP = join(ROOT, "overlap.mjs");
|
||||
const STATUS = join(ROOT, "status.mjs");
|
||||
const EXAMPLE = resolve(
|
||||
ROOT,
|
||||
"..",
|
||||
"..",
|
||||
"docs",
|
||||
"agent-workflows",
|
||||
"pilot-batch-example.json",
|
||||
);
|
||||
|
||||
function writeTempSpec(spec) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "agent-batch-test-"));
|
||||
const path = join(dir, "spec.json");
|
||||
writeFileSync(path, JSON.stringify(spec, null, 2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function run(script, args, opts = {}) {
|
||||
return spawnSync(process.execPath, [script, ...args], {
|
||||
encoding: "utf8",
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
test("validate.mjs exits 0 on the canonical example spec", () => {
|
||||
const r = run(VALIDATE, [EXAMPLE]);
|
||||
assert.strictEqual(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /ok: batch example-pilot-2026-05-15/);
|
||||
});
|
||||
|
||||
test("validate.mjs exits 1 on a spec with malformed branch", () => {
|
||||
const path = writeTempSpec({
|
||||
batch_id: "pilot-test",
|
||||
base_repo: "tinyhumansai/openhuman",
|
||||
base_branch: "main",
|
||||
tracking_issue: 1480,
|
||||
agents: [
|
||||
{
|
||||
id: "a01",
|
||||
issue: 100,
|
||||
title: "t",
|
||||
branch: "feature/foo",
|
||||
owned_paths: ["src/openhuman/foo/"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = run(VALIDATE, [path]);
|
||||
assert.strictEqual(r.status, 1, r.stdout);
|
||||
assert.match(r.stderr, /branch must match cursor/);
|
||||
});
|
||||
|
||||
test("overlap.mjs exits 0 on disjoint example", () => {
|
||||
const r = run(OVERLAP, [EXAMPLE]);
|
||||
assert.strictEqual(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /disjoint paths/);
|
||||
});
|
||||
|
||||
test("overlap.mjs exits 1 when two agents own the same prefix", () => {
|
||||
const path = writeTempSpec({
|
||||
batch_id: "pilot-test",
|
||||
base_repo: "tinyhumansai/openhuman",
|
||||
base_branch: "main",
|
||||
tracking_issue: 1480,
|
||||
agents: [
|
||||
{
|
||||
id: "a01",
|
||||
issue: 100,
|
||||
title: "t",
|
||||
branch: "cursor/a01-100-x",
|
||||
owned_paths: ["src/openhuman/foo/"],
|
||||
},
|
||||
{
|
||||
id: "a02",
|
||||
issue: 101,
|
||||
title: "t",
|
||||
branch: "cursor/a02-101-y",
|
||||
owned_paths: ["src/openhuman/foo/inner/"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const r = run(OVERLAP, [path]);
|
||||
assert.strictEqual(r.status, 1, r.stdout);
|
||||
assert.match(r.stderr, /ownership collision/);
|
||||
assert.match(r.stderr, /a01 ↔ a02/);
|
||||
});
|
||||
|
||||
test("status.mjs renders a markdown table from a fixture (no gh needed)", () => {
|
||||
const fixture = [
|
||||
{
|
||||
headRefName: "cursor/a01-9001-memory-namespace-logging",
|
||||
number: 5001,
|
||||
url: "https://github.com/tinyhumansai/openhuman/pull/5001",
|
||||
state: "OPEN",
|
||||
statusCheckRollup: "SUCCESS",
|
||||
},
|
||||
{
|
||||
headRefName: "cursor/a02-9002-cron-rpc-dedupe",
|
||||
number: 5002,
|
||||
url: "https://github.com/tinyhumansai/openhuman/pull/5002",
|
||||
state: "OPEN",
|
||||
statusCheckRollup: "FAILURE",
|
||||
},
|
||||
];
|
||||
const dir = mkdtempSync(join(tmpdir(), "agent-batch-test-"));
|
||||
const fixturePath = join(dir, "fixture.json");
|
||||
writeFileSync(fixturePath, JSON.stringify(fixture));
|
||||
const r = run(STATUS, [EXAMPLE, "--fixture", fixturePath]);
|
||||
assert.strictEqual(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /<!-- batch:example-pilot-2026-05-15 -->/);
|
||||
assert.match(r.stdout, /\| a01 \|.*\[#5001\]/);
|
||||
assert.match(r.stdout, /\| a02 \|.*failing/);
|
||||
// a03 has no PR in the fixture — must still appear with placeholders.
|
||||
assert.match(r.stdout, /\| a03 \|.*\| — \| — \| no pr \|/i);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
validateSpec,
|
||||
findOverlaps,
|
||||
parseArgs,
|
||||
SpecError,
|
||||
BRANCH_RE,
|
||||
} from "../lib.mjs";
|
||||
|
||||
function baseSpec(overrides = {}) {
|
||||
return {
|
||||
batch_id: "pilot-test",
|
||||
base_repo: "tinyhumansai/openhuman",
|
||||
base_branch: "main",
|
||||
tracking_issue: 1480,
|
||||
agents: [
|
||||
{
|
||||
id: "a01",
|
||||
issue: 100,
|
||||
title: "fix foo",
|
||||
branch: "cursor/a01-100-fix-foo",
|
||||
owned_paths: ["src/openhuman/foo/"],
|
||||
},
|
||||
{
|
||||
id: "a02",
|
||||
issue: 101,
|
||||
title: "fix bar",
|
||||
branch: "cursor/a02-101-fix-bar",
|
||||
owned_paths: ["app/src/components/bar/"],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("validateSpec accepts a well-formed spec", () => {
|
||||
const spec = baseSpec();
|
||||
assert.strictEqual(validateSpec(spec), spec);
|
||||
});
|
||||
|
||||
test("validateSpec rejects wrong base_repo", () => {
|
||||
const spec = baseSpec({ base_repo: "somefork/openhuman" });
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects wrong base_branch", () => {
|
||||
const spec = baseSpec({ base_branch: "develop" });
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects non-positive tracking_issue", () => {
|
||||
const spec = baseSpec({ tracking_issue: 0 });
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects empty agents array", () => {
|
||||
const spec = baseSpec({ agents: [] });
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects batch larger than the hard cap", () => {
|
||||
const agents = Array.from({ length: 26 }, (_, i) => {
|
||||
const id = `a${String(i + 1).padStart(2, "0")}`;
|
||||
return {
|
||||
id,
|
||||
issue: 1000 + i,
|
||||
title: "t",
|
||||
branch: `cursor/${id}-${1000 + i}-x`,
|
||||
owned_paths: [`src/openhuman/dom${i}/`],
|
||||
};
|
||||
});
|
||||
assert.throws(() => validateSpec(baseSpec({ agents })), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects malformed agent id", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].id = "agent-one";
|
||||
spec.agents[0].branch = "cursor/agent-one-100-fix-foo";
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects branch whose id segment does not match agent id", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].branch = "cursor/a99-100-fix-foo";
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects branch whose issue segment does not match agent issue", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].branch = "cursor/a01-999-fix-foo";
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects duplicate agent ids", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[1].id = "a01";
|
||||
spec.agents[1].branch = "cursor/a01-101-fix-bar";
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects duplicate issues", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[1].issue = 100;
|
||||
spec.agents[1].branch = "cursor/a02-100-fix-bar";
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects glob characters in owned_paths", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].owned_paths = ["src/openhuman/**"];
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("validateSpec rejects absolute paths", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].owned_paths = ["/etc/passwd"];
|
||||
assert.throws(() => validateSpec(spec), SpecError);
|
||||
});
|
||||
|
||||
test("findOverlaps returns empty for disjoint prefixes", () => {
|
||||
assert.deepStrictEqual(findOverlaps(baseSpec()), []);
|
||||
});
|
||||
|
||||
test("findOverlaps detects identical paths", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[1].owned_paths = ["src/openhuman/foo/"];
|
||||
const collisions = findOverlaps(spec);
|
||||
assert.strictEqual(collisions.length, 1);
|
||||
assert.strictEqual(collisions[0].reason, "exact");
|
||||
});
|
||||
|
||||
test("findOverlaps detects prefix containment in either direction", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[1].owned_paths = ["src/openhuman/foo/sub/"];
|
||||
const collisions = findOverlaps(spec);
|
||||
assert.strictEqual(collisions.length, 1);
|
||||
assert.strictEqual(collisions[0].reason, "prefix");
|
||||
});
|
||||
|
||||
test("findOverlaps ignores paths listed in the other agent's allowed_shared_paths", () => {
|
||||
const spec = baseSpec();
|
||||
spec.agents[0].owned_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
|
||||
spec.agents[1].owned_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
|
||||
spec.agents[0].allowed_shared_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
|
||||
spec.agents[1].allowed_shared_paths = ["docs/TEST-COVERAGE-MATRIX.md"];
|
||||
assert.deepStrictEqual(findOverlaps(spec), []);
|
||||
});
|
||||
|
||||
test("BRANCH_RE accepts well-formed branches", () => {
|
||||
assert.ok(BRANCH_RE.test("cursor/a01-1234-short-title"));
|
||||
assert.ok(BRANCH_RE.test("cursor/a100-99-x"));
|
||||
});
|
||||
|
||||
test("BRANCH_RE rejects malformed branches", () => {
|
||||
assert.ok(!BRANCH_RE.test("feature/foo"));
|
||||
assert.ok(!BRANCH_RE.test("cursor/a01-1234"));
|
||||
assert.ok(!BRANCH_RE.test("cursor/a01-abc-foo"));
|
||||
assert.ok(!BRANCH_RE.test("cursor/A01-1234-foo"));
|
||||
});
|
||||
|
||||
test("parseArgs splits positional, --flag, --flag=v, --flag v", () => {
|
||||
const r = parseArgs([
|
||||
"spec.json",
|
||||
"--post",
|
||||
"--fixture=foo.json",
|
||||
"--agent",
|
||||
"a02",
|
||||
]);
|
||||
assert.deepStrictEqual(r.positional, ["spec.json"]);
|
||||
assert.strictEqual(r.flags.post, true);
|
||||
assert.strictEqual(r.flags.fixture, "foo.json");
|
||||
assert.strictEqual(r.flags.agent, "a02");
|
||||
});
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
// Thin dispatcher so `pnpm agent-batch <verb> <spec> [...]` works.
|
||||
// Verbs map to sibling .mjs scripts.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VERBS = new Set(["validate", "overlap", "launch", "status"]);
|
||||
|
||||
function usage(code = 2) {
|
||||
process.stderr.write(
|
||||
"usage: pnpm agent-batch <validate|overlap|launch|status> <spec.json> [...]\n",
|
||||
);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const [verb, ...rest] = process.argv.slice(2);
|
||||
if (!verb || verb === "--help" || verb === "-h") usage(verb ? 0 : 2);
|
||||
if (!VERBS.has(verb)) {
|
||||
process.stderr.write(`[agent-batch] unknown verb "${verb}"\n`);
|
||||
usage();
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const child = spawn(process.execPath, [join(here, `${verb}.mjs`), ...rest], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
child.on("exit", (code) => process.exit(code ?? 1));
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env node
|
||||
// Print one launch comment per agent in the batch.
|
||||
// The operator pastes each into Cursor as the agent prompt.
|
||||
// Usage:
|
||||
// node scripts/agent-batch/launch.mjs <spec.json> [--agent <id>] [--print-only]
|
||||
//
|
||||
// `--print-only` is the default and the only mode currently supported; the
|
||||
// flag exists so callers can be explicit and so a future `--post` mode (e.g.
|
||||
// posting directly to a Cursor API) is unambiguous.
|
||||
|
||||
import {
|
||||
loadSpec,
|
||||
validateSpec,
|
||||
findOverlaps,
|
||||
SpecError,
|
||||
parseArgs,
|
||||
} from "./lib.mjs";
|
||||
|
||||
function renderComment(spec, agent) {
|
||||
const owned = agent.owned_paths.map((p) => ` - \`${p}\``).join("\n");
|
||||
const shared = (agent.allowed_shared_paths ?? [])
|
||||
.map((p) => ` - \`${p}\``)
|
||||
.join("\n");
|
||||
return `@Cursor work issue #${agent.issue} for batch \`${spec.batch_id}\`.
|
||||
|
||||
Repo: ${spec.base_repo}
|
||||
Base: latest \`${spec.base_branch}\` from upstream
|
||||
Branch: \`${agent.branch}\`
|
||||
PR target: ${spec.base_repo}:${spec.base_branch} (from your fork)
|
||||
|
||||
Owned paths (you MAY edit these and nothing else outside the shared-paths list):
|
||||
${owned}
|
||||
|
||||
${shared ? `Allowed shared paths (touch only if strictly necessary):\n${shared}\n` : ""}
|
||||
Required reading before editing:
|
||||
- docs/agent-workflows/cursor-cloud-agents.md
|
||||
- docs/agent-workflows/codex-pr-checklist.md
|
||||
- .github/PULL_REQUEST_TEMPLATE.md
|
||||
- AGENTS.md and CLAUDE.md
|
||||
|
||||
Pre-PR validation (run all, report any blocker exactly in the PR body):
|
||||
- pnpm --filter openhuman-app format:check
|
||||
- pnpm typecheck
|
||||
- pnpm lint
|
||||
- focused vitest for changed TS/React files
|
||||
- cargo fmt --manifest-path Cargo.toml --all --check (if Rust changed)
|
||||
- focused \`pnpm debug rust <filter>\` for changed Rust
|
||||
- pnpm test:coverage and pnpm test:rust — coverage on changed lines must be ≥ 80%
|
||||
|
||||
PR rules:
|
||||
- Title: \`<area>: ${agent.title} (#${agent.issue})\`
|
||||
- Body MUST follow .github/PULL_REQUEST_TEMPLATE.md verbatim, including the AI Authored PR Metadata section.
|
||||
- Add labels: ${(agent.labels ?? ["cursor-agent"]).map((l) => `\`${l}\``).join(", ")}
|
||||
- One PR per issue. Do not open duplicates. If retrying, update the existing PR.
|
||||
- Push to your fork; open the PR with \`--head <fork-owner>:${agent.branch}\` against ${spec.base_repo}:${spec.base_branch}.
|
||||
- Close the issue with \`Closes #${agent.issue}\` in the Related section.
|
||||
|
||||
Tracking: progress for this batch is reported on issue #${spec.tracking_issue}.
|
||||
`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { positional, flags } = parseArgs(process.argv.slice(2));
|
||||
const specPath = positional[0];
|
||||
if (!specPath) {
|
||||
process.stderr.write(
|
||||
"usage: launch.mjs <spec.json> [--agent <id>] [--print-only]\n",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
let spec;
|
||||
try {
|
||||
spec = validateSpec(loadSpec(specPath));
|
||||
} catch (e) {
|
||||
if (e instanceof SpecError) {
|
||||
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const collisions = findOverlaps(spec);
|
||||
if (collisions.length > 0) {
|
||||
process.stderr.write(
|
||||
`[agent-batch] refusing to launch: ${collisions.length} ownership collision(s) — run overlap.mjs\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const onlyId = typeof flags.agent === "string" ? flags.agent : null;
|
||||
const agents = onlyId
|
||||
? spec.agents.filter((a) => a.id === onlyId)
|
||||
: spec.agents;
|
||||
if (onlyId && agents.length === 0) {
|
||||
process.stderr.write(
|
||||
`[agent-batch] no agent with id "${onlyId}" in spec\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const agent of agents) {
|
||||
process.stdout.write(`\n===== agent ${agent.id} (#${agent.issue}) =====\n`);
|
||||
process.stdout.write(renderComment(spec, agent));
|
||||
}
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,247 @@
|
||||
// Shared helpers for the agent-batch tooling.
|
||||
// Zero dependencies — Node 20+ stdlib only.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export const BRANCH_RE = /^cursor\/(a\d{2,3})-(\d+)-[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
const REQUIRED_TOP = [
|
||||
"batch_id",
|
||||
"base_repo",
|
||||
"base_branch",
|
||||
"tracking_issue",
|
||||
"agents",
|
||||
];
|
||||
const REQUIRED_AGENT = ["id", "issue", "title", "branch", "owned_paths"];
|
||||
|
||||
export class SpecError extends Error {
|
||||
constructor(message, path) {
|
||||
super(path ? `${path}: ${message}` : message);
|
||||
this.path = path;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSpec(filePath) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(filePath, "utf8");
|
||||
} catch (e) {
|
||||
throw new SpecError(`cannot read ${filePath}: ${e.message}`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new SpecError(`invalid JSON in ${filePath}: ${e.message}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
// Validate a parsed spec. Returns the spec on success, throws SpecError on
|
||||
// any policy violation. The caller is responsible for printing.
|
||||
export function validateSpec(spec) {
|
||||
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
||||
throw new SpecError("spec must be a JSON object");
|
||||
}
|
||||
for (const key of REQUIRED_TOP) {
|
||||
if (!(key in spec))
|
||||
throw new SpecError(`missing required top-level field "${key}"`);
|
||||
}
|
||||
if (
|
||||
typeof spec.batch_id !== "string" ||
|
||||
!/^[a-z0-9][a-z0-9-]*$/.test(spec.batch_id)
|
||||
) {
|
||||
throw new SpecError("batch_id must be a kebab-case slug", "batch_id");
|
||||
}
|
||||
if (spec.base_repo !== "tinyhumansai/openhuman") {
|
||||
throw new SpecError(
|
||||
`base_repo must be "tinyhumansai/openhuman" (got "${spec.base_repo}")`,
|
||||
"base_repo",
|
||||
);
|
||||
}
|
||||
if (spec.base_branch !== "main") {
|
||||
throw new SpecError(
|
||||
`base_branch must be "main" (got "${spec.base_branch}")`,
|
||||
"base_branch",
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(spec.tracking_issue) || spec.tracking_issue <= 0) {
|
||||
throw new SpecError(
|
||||
"tracking_issue must be a positive integer",
|
||||
"tracking_issue",
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(spec.agents) || spec.agents.length === 0) {
|
||||
throw new SpecError("agents must be a non-empty array", "agents");
|
||||
}
|
||||
if (spec.agents.length > 25) {
|
||||
throw new SpecError(
|
||||
`batch size ${spec.agents.length} exceeds hard cap of 25`,
|
||||
"agents",
|
||||
);
|
||||
}
|
||||
|
||||
const seenId = new Set();
|
||||
const seenIssue = new Set();
|
||||
const seenBranch = new Set();
|
||||
for (let i = 0; i < spec.agents.length; i++) {
|
||||
const agent = spec.agents[i];
|
||||
const at = `agents[${i}]`;
|
||||
if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
|
||||
throw new SpecError("must be an object", at);
|
||||
}
|
||||
for (const key of REQUIRED_AGENT) {
|
||||
if (!(key in agent))
|
||||
throw new SpecError(`missing required field "${key}"`, at);
|
||||
}
|
||||
if (typeof agent.id !== "string" || !/^a\d{2,3}$/.test(agent.id)) {
|
||||
throw new SpecError(
|
||||
`id must match /^a\\d{2,3}$/ (got "${agent.id}")`,
|
||||
`${at}.id`,
|
||||
);
|
||||
}
|
||||
if (seenId.has(agent.id))
|
||||
throw new SpecError(`duplicate id "${agent.id}"`, `${at}.id`);
|
||||
seenId.add(agent.id);
|
||||
if (!Number.isInteger(agent.issue) || agent.issue <= 0) {
|
||||
throw new SpecError("issue must be a positive integer", `${at}.issue`);
|
||||
}
|
||||
if (seenIssue.has(agent.issue)) {
|
||||
throw new SpecError(`duplicate issue #${agent.issue}`, `${at}.issue`);
|
||||
}
|
||||
seenIssue.add(agent.issue);
|
||||
if (typeof agent.title !== "string" || agent.title.trim().length === 0) {
|
||||
throw new SpecError("title must be a non-empty string", `${at}.title`);
|
||||
}
|
||||
const m = BRANCH_RE.exec(agent.branch);
|
||||
if (!m) {
|
||||
throw new SpecError(
|
||||
`branch must match cursor/<id>-<issue>-<slug> (got "${agent.branch}")`,
|
||||
`${at}.branch`,
|
||||
);
|
||||
}
|
||||
if (m[1] !== agent.id) {
|
||||
throw new SpecError(
|
||||
`branch id segment "${m[1]}" does not match agent id "${agent.id}"`,
|
||||
`${at}.branch`,
|
||||
);
|
||||
}
|
||||
if (Number(m[2]) !== agent.issue) {
|
||||
throw new SpecError(
|
||||
`branch issue segment "${m[2]}" does not match agent issue ${agent.issue}`,
|
||||
`${at}.branch`,
|
||||
);
|
||||
}
|
||||
if (seenBranch.has(agent.branch)) {
|
||||
throw new SpecError(`duplicate branch "${agent.branch}"`, `${at}.branch`);
|
||||
}
|
||||
seenBranch.add(agent.branch);
|
||||
if (!Array.isArray(agent.owned_paths) || agent.owned_paths.length === 0) {
|
||||
throw new SpecError(
|
||||
"owned_paths must be a non-empty array",
|
||||
`${at}.owned_paths`,
|
||||
);
|
||||
}
|
||||
for (let j = 0; j < agent.owned_paths.length; j++) {
|
||||
const p = agent.owned_paths[j];
|
||||
if (typeof p !== "string" || p.length === 0) {
|
||||
throw new SpecError(
|
||||
"must be a non-empty string",
|
||||
`${at}.owned_paths[${j}]`,
|
||||
);
|
||||
}
|
||||
if (p.includes("*") || p.includes("?")) {
|
||||
throw new SpecError(
|
||||
`globs not allowed — use directory prefixes (got "${p}")`,
|
||||
`${at}.owned_paths[${j}]`,
|
||||
);
|
||||
}
|
||||
if (p.startsWith("/")) {
|
||||
throw new SpecError(
|
||||
`paths must be repo-relative, not absolute (got "${p}")`,
|
||||
`${at}.owned_paths[${j}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if ("allowed_shared_paths" in agent) {
|
||||
if (!Array.isArray(agent.allowed_shared_paths)) {
|
||||
throw new SpecError("must be an array", `${at}.allowed_shared_paths`);
|
||||
}
|
||||
}
|
||||
if ("labels" in agent && !Array.isArray(agent.labels)) {
|
||||
throw new SpecError("must be an array", `${at}.labels`);
|
||||
}
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
// Pure overlap detection: returns an array of collisions. Each entry is
|
||||
// { a, b, pathA, pathB, reason } where reason is "prefix" (one contains the
|
||||
// other) or "exact" (identical). Empty array = disjoint.
|
||||
//
|
||||
// Shared paths in allowed_shared_paths are NOT considered collisions — they
|
||||
// are escape hatches for unavoidably-shared files like the coverage matrix.
|
||||
export function findOverlaps(spec) {
|
||||
const collisions = [];
|
||||
for (let i = 0; i < spec.agents.length; i++) {
|
||||
for (let j = i + 1; j < spec.agents.length; j++) {
|
||||
const a = spec.agents[i];
|
||||
const b = spec.agents[j];
|
||||
const sharedA = new Set(a.allowed_shared_paths ?? []);
|
||||
const sharedB = new Set(b.allowed_shared_paths ?? []);
|
||||
for (const pa of a.owned_paths) {
|
||||
if (sharedB.has(pa)) continue;
|
||||
for (const pb of b.owned_paths) {
|
||||
if (sharedA.has(pb)) continue;
|
||||
const c = comparePaths(pa, pb);
|
||||
if (c) {
|
||||
collisions.push({
|
||||
a: a.id,
|
||||
b: b.id,
|
||||
pathA: pa,
|
||||
pathB: pb,
|
||||
reason: c,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return collisions;
|
||||
}
|
||||
|
||||
function comparePaths(p1, p2) {
|
||||
if (p1 === p2) return "exact";
|
||||
const n1 = p1.endsWith("/") ? p1 : p1 + "/";
|
||||
const n2 = p2.endsWith("/") ? p2 : p2 + "/";
|
||||
if (n1.startsWith(n2) || n2.startsWith(n1)) return "prefix";
|
||||
return null;
|
||||
}
|
||||
|
||||
// CLI helper: parse argv after the script name and return { positional, flags }.
|
||||
// Recognizes `--flag` and `--flag=value` and `--flag value`.
|
||||
export function parseArgs(argv) {
|
||||
const positional = [];
|
||||
const flags = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith("--")) {
|
||||
const eq = a.indexOf("=");
|
||||
if (eq !== -1) {
|
||||
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
||||
} else {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith("--")) {
|
||||
flags[key] = true;
|
||||
} else {
|
||||
flags[key] = next;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
return { positional, flags };
|
||||
}
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
// Prove ownership disjointness in a batch spec.
|
||||
// Usage: node scripts/agent-batch/overlap.mjs <spec.json>
|
||||
// Exits 0 if all agents own disjoint path prefixes, 1 on any collision.
|
||||
|
||||
import {
|
||||
loadSpec,
|
||||
validateSpec,
|
||||
findOverlaps,
|
||||
SpecError,
|
||||
parseArgs,
|
||||
} from "./lib.mjs";
|
||||
|
||||
function main() {
|
||||
const { positional } = parseArgs(process.argv.slice(2));
|
||||
const specPath = positional[0];
|
||||
if (!specPath) {
|
||||
process.stderr.write("usage: overlap.mjs <spec.json>\n");
|
||||
process.exit(2);
|
||||
}
|
||||
let spec;
|
||||
try {
|
||||
spec = validateSpec(loadSpec(specPath));
|
||||
} catch (e) {
|
||||
if (e instanceof SpecError) {
|
||||
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const collisions = findOverlaps(spec);
|
||||
if (collisions.length === 0) {
|
||||
process.stdout.write(
|
||||
`[agent-batch] ok: ${spec.agents.length} agent(s) own disjoint paths\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write(
|
||||
`[agent-batch] ${collisions.length} ownership collision(s):\n`,
|
||||
);
|
||||
for (const c of collisions) {
|
||||
process.stderr.write(
|
||||
` ${c.a} ↔ ${c.b}: "${c.pathA}" vs "${c.pathB}" (${c.reason})\n`,
|
||||
);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env node
|
||||
// Render a markdown progress table for an agent batch.
|
||||
// Usage:
|
||||
// node scripts/agent-batch/status.mjs <spec.json> # query gh, print to stdout
|
||||
// node scripts/agent-batch/status.mjs <spec.json> --post # also rewrite the tracking comment
|
||||
// node scripts/agent-batch/status.mjs <spec.json> --fixture <file.json>
|
||||
// (for tests — read PR data from a JSON fixture instead of shelling out to gh)
|
||||
//
|
||||
// Fixture shape (array of records keyed by branch):
|
||||
// [{ "headRefName": "cursor/a01-…", "number": 1234, "url": "…",
|
||||
// "state": "OPEN" | "MERGED" | "CLOSED",
|
||||
// "statusCheckRollup": "SUCCESS" | "FAILURE" | "PENDING" | null }]
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { loadSpec, validateSpec, SpecError, parseArgs } from "./lib.mjs";
|
||||
|
||||
const COMMENT_MARKER = (id) => `<!-- batch:${id} -->`;
|
||||
|
||||
function ciCell(rollup) {
|
||||
if (rollup === "SUCCESS") return "green";
|
||||
if (rollup === "FAILURE") return "failing";
|
||||
if (rollup === "PENDING") return "pending";
|
||||
return "—";
|
||||
}
|
||||
|
||||
function stateCell(state) {
|
||||
if (!state) return "no PR";
|
||||
return state.toLowerCase();
|
||||
}
|
||||
|
||||
function renderTable(spec, prsByBranch) {
|
||||
const lines = [];
|
||||
lines.push(COMMENT_MARKER(spec.batch_id));
|
||||
lines.push(`## Batch \`${spec.batch_id}\` — progress`);
|
||||
lines.push("");
|
||||
lines.push("| Agent | Issue | Branch | PR | CI | Status |");
|
||||
lines.push("| --- | --- | --- | --- | --- | --- |");
|
||||
for (const agent of spec.agents) {
|
||||
const pr = prsByBranch.get(agent.branch) ?? null;
|
||||
const prCell = pr ? `[#${pr.number}](${pr.url})` : "—";
|
||||
const ci = pr ? ciCell(pr.statusCheckRollup) : "—";
|
||||
const state = stateCell(pr?.state);
|
||||
lines.push(
|
||||
`| ${agent.id} | [#${agent.issue}](https://github.com/${spec.base_repo}/issues/${agent.issue}) | \`${agent.branch}\` | ${prCell} | ${ci} | ${state} |`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function fetchPrsFromGh(spec) {
|
||||
// One `gh pr list` call per batch — cheap and avoids N+1.
|
||||
const args = [
|
||||
"pr",
|
||||
"list",
|
||||
"--repo",
|
||||
spec.base_repo,
|
||||
"--state",
|
||||
"all",
|
||||
"--search",
|
||||
`label:batch:${spec.batch_id}`,
|
||||
"--json",
|
||||
"headRefName,number,url,state,statusCheckRollup",
|
||||
"--limit",
|
||||
"100",
|
||||
];
|
||||
const r = spawnSync("gh", args, { encoding: "utf8" });
|
||||
if (r.status !== 0) {
|
||||
throw new Error(
|
||||
`gh failed (${r.status}): ${r.stderr?.trim() || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return JSON.parse(r.stdout || "[]");
|
||||
}
|
||||
|
||||
function indexByBranch(prs) {
|
||||
const m = new Map();
|
||||
for (const pr of prs) {
|
||||
// `statusCheckRollup` from gh is an array of contexts when populated; we
|
||||
// only care about the worst-status rollup for the cell.
|
||||
let rollup = null;
|
||||
if (
|
||||
Array.isArray(pr.statusCheckRollup) &&
|
||||
pr.statusCheckRollup.length > 0
|
||||
) {
|
||||
const states = pr.statusCheckRollup
|
||||
.map((c) => c.conclusion || c.state)
|
||||
.filter(Boolean);
|
||||
if (
|
||||
states.some(
|
||||
(s) => s === "FAILURE" || s === "CANCELLED" || s === "TIMED_OUT",
|
||||
)
|
||||
) {
|
||||
rollup = "FAILURE";
|
||||
} else if (states.every((s) => s === "SUCCESS")) {
|
||||
rollup = "SUCCESS";
|
||||
} else {
|
||||
rollup = "PENDING";
|
||||
}
|
||||
} else if (typeof pr.statusCheckRollup === "string") {
|
||||
// Fixture-friendly shape.
|
||||
rollup = pr.statusCheckRollup;
|
||||
}
|
||||
m.set(pr.headRefName, {
|
||||
number: pr.number,
|
||||
url: pr.url,
|
||||
state: pr.state,
|
||||
statusCheckRollup: rollup,
|
||||
});
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function postOrUpdateTrackingComment(spec, body) {
|
||||
const issue = spec.tracking_issue;
|
||||
const list = spawnSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
`repos/${spec.base_repo}/issues/${issue}/comments`,
|
||||
"--paginate",
|
||||
"--jq",
|
||||
// Emit one object per line. `gh api --paginate` runs the jq filter
|
||||
// per-page; wrapping in `[...]` would produce concatenated array
|
||||
// fragments that aren't valid JSON. NDJSON sidesteps that.
|
||||
`.[] | select(.body | contains("${COMMENT_MARKER(spec.batch_id)}")) | {id, html_url}`,
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
if (list.status !== 0) {
|
||||
throw new Error(
|
||||
`gh api failed (${list.status}): ${list.stderr?.trim() || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
const existing = (list.stdout || "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => JSON.parse(line));
|
||||
if (existing.length === 0) {
|
||||
const r = spawnSync(
|
||||
"gh",
|
||||
[
|
||||
"issue",
|
||||
"comment",
|
||||
String(issue),
|
||||
"--repo",
|
||||
spec.base_repo,
|
||||
"--body-file",
|
||||
"-",
|
||||
],
|
||||
{ encoding: "utf8", input: body },
|
||||
);
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`gh issue comment failed: ${r.stderr?.trim() || ""}`);
|
||||
}
|
||||
process.stdout.write(
|
||||
`[agent-batch] posted new tracking comment on #${issue}\n`,
|
||||
);
|
||||
} else {
|
||||
const id = existing[0].id;
|
||||
// Pass the comment body via stdin (-F body=@-) rather than a command-line
|
||||
// arg. Long markdown tables can grow large and -f body=${body} risks
|
||||
// hitting OS argv length limits (ARG_MAX).
|
||||
const r = spawnSync(
|
||||
"gh",
|
||||
[
|
||||
"api",
|
||||
"--method",
|
||||
"PATCH",
|
||||
`repos/${spec.base_repo}/issues/comments/${id}`,
|
||||
"-F",
|
||||
"body=@-",
|
||||
],
|
||||
{ encoding: "utf8", input: body },
|
||||
);
|
||||
if (r.status !== 0) {
|
||||
throw new Error(`gh api PATCH failed: ${r.stderr?.trim() || ""}`);
|
||||
}
|
||||
process.stdout.write(
|
||||
`[agent-batch] updated tracking comment ${existing[0].html_url}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { positional, flags } = parseArgs(process.argv.slice(2));
|
||||
const specPath = positional[0];
|
||||
if (!specPath) {
|
||||
process.stderr.write(
|
||||
"usage: status.mjs <spec.json> [--post] [--fixture <file>]\n",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
let spec;
|
||||
try {
|
||||
spec = validateSpec(loadSpec(specPath));
|
||||
} catch (e) {
|
||||
if (e instanceof SpecError) {
|
||||
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
let prs;
|
||||
if (typeof flags.fixture === "string") {
|
||||
prs = JSON.parse(readFileSync(flags.fixture, "utf8"));
|
||||
} else {
|
||||
prs = fetchPrsFromGh(spec);
|
||||
}
|
||||
const body = renderTable(spec, indexByBranch(prs));
|
||||
process.stdout.write(body);
|
||||
|
||||
if (flags.post) {
|
||||
postOrUpdateTrackingComment(spec, body);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
// Validate a Cursor Cloud Agents batch spec.
|
||||
// Usage: node scripts/agent-batch/validate.mjs <spec.json>
|
||||
// Exits 0 on success, 1 on any policy violation. Prints a one-line summary
|
||||
// on success and a structured error otherwise.
|
||||
|
||||
import { loadSpec, validateSpec, SpecError, parseArgs } from "./lib.mjs";
|
||||
|
||||
function main() {
|
||||
const { positional } = parseArgs(process.argv.slice(2));
|
||||
const specPath = positional[0];
|
||||
if (!specPath) {
|
||||
process.stderr.write("usage: validate.mjs <spec.json>\n");
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const spec = validateSpec(loadSpec(specPath));
|
||||
process.stdout.write(
|
||||
`[agent-batch] ok: batch ${spec.batch_id} with ${spec.agents.length} agent(s)\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
if (e instanceof SpecError) {
|
||||
process.stderr.write(`[agent-batch] spec error: ${e.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stderr.write(
|
||||
`[agent-batch] unexpected error: ${e.stack || e.message}\n`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user