auto-harness-client 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/package.json +1 -1
- package/src/cli/args.js +17 -1
- package/src/cli/commands/attach-repository.js +117 -0
- package/src/cli/commands/detach-repository.js +109 -0
- package/src/cli/commands/host-repo-add.js +81 -0
- package/src/cli/commands/host-repo-rm.js +9 -86
- package/src/cli/commands/host-repo.js +7 -2
- package/src/cli/commands/host-smoke-format.js +30 -0
- package/src/cli/commands/host-smoke-poll.js +77 -0
- package/src/cli/commands/host-smoke-provider.js +172 -0
- package/src/cli/commands/host-smoke-repository.js +49 -0
- package/src/cli/commands/host-smoke-session-attempt.js +104 -0
- package/src/cli/commands/host-smoke-teardown.js +164 -0
- package/src/cli/commands/host-smoke.js +151 -0
- package/src/cli/commands/host.js +7 -1
- package/src/cli/commands/parse-worktree-flag.js +30 -0
- package/src/cli/commands/repo-add.js +38 -0
- package/src/cli/commands/repo.js +3 -0
- package/src/cli/commands/session-cancel.js +28 -0
- package/src/cli/commands/session-create.js +117 -0
- package/src/cli/commands/session-get.js +26 -0
- package/src/cli/commands/session-logs.js +65 -0
- package/src/cli/commands/session-target.js +29 -0
- package/src/cli/commands/session.js +22 -0
- package/src/cli/main.js +2 -0
- package/src/cli/session-format.js +15 -0
- package/src/cli/usage.js +17 -0
- package/src/cli/wait-for-session.js +46 -0
package/README.md
CHANGED
|
@@ -375,6 +375,36 @@ Because the write replaces the whole record, omitting `providerAccounts` wipes t
|
|
|
375
375
|
provider routing — always build the new document from a fresh `inventory get --json`, editing
|
|
376
376
|
only what you mean to change.
|
|
377
377
|
|
|
378
|
+
#### `auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]... [--default-branch <branch>] [--dry-run] [--json]`
|
|
379
|
+
|
|
380
|
+
Attaches an already-registered repository (create it first with `auto-harness repo add`) to a
|
|
381
|
+
host's inventory — the counterpart of `host repo rm`. Same safe read-modify-write shape:
|
|
382
|
+
|
|
383
|
+
1. `GET /repositories/<repositoryId>` — a 404 fails with a clear message naming the id.
|
|
384
|
+
`--default-branch` defaults to that repository's own `defaultBranch` when omitted.
|
|
385
|
+
2. `GET` the host's inventory. If this repository id is already attached, it fails (exit 1)
|
|
386
|
+
naming the path it is attached at — this command never overwrites an existing attachment;
|
|
387
|
+
remove it first with `host repo rm`.
|
|
388
|
+
3. `--dry-run` prints what would be attached and exits without writing.
|
|
389
|
+
4. Otherwise builds the new document as the record exactly as read, with the new entry appended
|
|
390
|
+
to `repositories` and the read `version` kept — every other field, including
|
|
391
|
+
`providerAccounts`, is preserved untouched.
|
|
392
|
+
5. On a `409` (someone else wrote first) it re-reads and re-applies, up to 3 attempts total. If
|
|
393
|
+
the repository is now attached at the same path, that is treated as convergence (another
|
|
394
|
+
writer already did what this call wanted) rather than an error; a different path fails,
|
|
395
|
+
naming what is actually attached. Any other error status is not retried.
|
|
396
|
+
6. On success it prints what was attached and the version transition (e.g. `version 29 → 30`).
|
|
397
|
+
|
|
398
|
+
`--worktree <id>=<path>` is repeatable and adds one worktree entry per occurrence (split on the
|
|
399
|
+
first `=`, so a path containing `=` still parses); a malformed value or a repeated id is a usage
|
|
400
|
+
error (exit 2) before any request is made. Each becomes `{ id, name: id, path, labels: [] }` —
|
|
401
|
+
`name` mirrors `id`, and the worktree's own name/slug shape is validated server-side.
|
|
402
|
+
|
|
403
|
+
```sh
|
|
404
|
+
auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --dry-run
|
|
405
|
+
auto-harness host repo add host-1 repo-1 --path /repos/repo-1 --worktree wt-1=/repos/repo-1/wt-1
|
|
406
|
+
```
|
|
407
|
+
|
|
378
408
|
#### `auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`
|
|
379
409
|
|
|
380
410
|
Detaches one repository from a host. This exists because the only prior way to do it was to
|
|
@@ -405,10 +435,98 @@ auto-harness host repo rm host-1 repo-1 --dry-run
|
|
|
405
435
|
auto-harness host repo rm host-1 repo-1
|
|
406
436
|
```
|
|
407
437
|
|
|
438
|
+
#### `auto-harness host smoke <hostId> --repo-path <path> --provider <id|name> [--provider <id|name>]... [--timeout <seconds>] [--json]`
|
|
439
|
+
|
|
440
|
+
Proves a host can run a real provider-routed session end to end, then cleans up after itself —
|
|
441
|
+
useful after standing up a new host, or after touching its execution profiles, without having to
|
|
442
|
+
open the control plane UI. It never needs to be run against production to be trusted: the CI
|
|
443
|
+
end-to-end suite (`e2e/control/cli-host-smoke.spec.ts`) exercises this exact command against a
|
|
444
|
+
real API, a real in-process host daemon, and a real (`echo`-backed) provider on every change.
|
|
445
|
+
|
|
446
|
+
**Preconditions this command cannot check itself:** `--repo-path` names a directory on the
|
|
447
|
+
**host**, not on whatever machine runs this CLI — they may be different machines entirely — so
|
|
448
|
+
this command never calls `existsSync` or otherwise inspects it locally. That path must already
|
|
449
|
+
be a git repository with a clean `main` checkout, and its `.gitignore` must exclude
|
|
450
|
+
`.worktrees/`, since this command attaches one worktree at `<repo-path>/.worktrees/<name>`,
|
|
451
|
+
named after the throwaway repository. Worktree names are unique across the whole fleet, so each
|
|
452
|
+
run uses a fresh name and concurrent smokes on different hosts never collide.
|
|
453
|
+
|
|
454
|
+
What it does, in order, always tearing down in a `finally` no matter which step failed:
|
|
455
|
+
|
|
456
|
+
1. **Create** a throwaway repository (`POST /repositories`) with a unique, valid (slug) name.
|
|
457
|
+
Its `url` is a syntactically valid but inert `https://example.test/<name>.git` placeholder —
|
|
458
|
+
the daemon dispatches sessions against the host-local path this same run attaches, never a
|
|
459
|
+
repository's `url`, so nothing ever needs to resolve or dial it.
|
|
460
|
+
2. **Attach** it to `<hostId>`'s inventory via the same `attachRepository` read-modify-write
|
|
461
|
+
`host repo add` uses, with one worktree named after the repository.
|
|
462
|
+
3. **For each `--provider`, in order** (accepts an id or a name, exactly like `session create`):
|
|
463
|
+
create a session targeting it with the prompt `Reply with exactly: <MARKER>` (`MARKER` is
|
|
464
|
+
random and unique per run), wait for it, then fetch one page of its logs. A provider `PASS`es
|
|
465
|
+
only if the session `completed` with `exitCode` `0` **and** its stdout contains `MARKER`.
|
|
466
|
+
- **The host racing its own inventory poll:** a host only learns about a newly attached
|
|
467
|
+
repository through its own periodic poll — there is no push-on-write — so the very first
|
|
468
|
+
session against the repository this command _just_ attached routinely loses that race in
|
|
469
|
+
any real deployment, not only here: the host rejects it with a `setup_failed` session whose
|
|
470
|
+
`errorMessage` is exactly `Unknown repository: <id>` (`services/host-daemon/src/
|
|
471
|
+
worktree-manager.ts`), which the control plane never retries on its own. This command
|
|
472
|
+
recognizes that one exact, unambiguous shape and retries with it doubling backoff (up to 5
|
|
473
|
+
attempts, capped at 16s between attempts) — bounded by the same `--timeout` deadline as
|
|
474
|
+
everything else — before giving up and reporting it like any other failure. Any other
|
|
475
|
+
`setup_failed` (a real checkout/setup problem) is never retried.
|
|
476
|
+
- **Usage limits:** the control plane does not fail a session whose provider account hit its
|
|
477
|
+
usage limit — it requeues the session (`errorCode: "usage_limit"`) and puts the account on
|
|
478
|
+
cooldown instead (see `services/api/src/session-transition-planner.ts`'s
|
|
479
|
+
`planUsageLimit()`). This command checks for that on every poll (not only a status change,
|
|
480
|
+
since a requeued session's status can go right back to `"queued"` with no visible
|
|
481
|
+
transition) and fails that one provider immediately with "provider account hit its usage
|
|
482
|
+
limit" — it never sits out the rest of `--timeout` waiting for a cooldown to end.
|
|
483
|
+
- **On timeout**, the session is cancelled (this command owns it) and the provider fails with
|
|
484
|
+
a hint keyed off its last status: stuck in `queued` usually means no online host advertises
|
|
485
|
+
a ready execution profile for that provider's account (`HARNESS_EXECUTION_PROFILES`), or
|
|
486
|
+
nothing is running the scheduler.
|
|
487
|
+
- **A genuine polling failure** (a `getSession` network error or a `5xx`, not the usage-limit
|
|
488
|
+
or setup-failure shapes above) fails only that one provider with `session_wait_failed` — it
|
|
489
|
+
never aborts the run, so every remaining `--provider` still gets its own attempt. The
|
|
490
|
+
session is left for teardown's own safety net to cancel.
|
|
491
|
+
4. **Teardown**, always: cancel any session this run created that isn't already terminal, detach
|
|
492
|
+
the repository (only if it was actually attached), then `DELETE` it. The delete retries a `409`
|
|
493
|
+
(the worktree/host-inventory projection the delete guard reads can lag the detach write
|
|
494
|
+
teardown just made) and a transient failure (`5xx`, or a network/timeout error) a few times
|
|
495
|
+
with a short backoff before giving up; once a transient failure has actually happened, a later
|
|
496
|
+
`404` is treated as success (the delete most likely landed and the response never arrived). If
|
|
497
|
+
a session could not be cancelled (anything other than a `409`, which just means it was already
|
|
498
|
+
terminal), or the repository is left behind, this command exits `1` and prints the exact
|
|
499
|
+
`session cancel` and/or `host repo rm`/`repo rm` commands needed to finish cleanup by hand.
|
|
500
|
+
|
|
501
|
+
Exit `0` only when every provider passed **and** teardown itself succeeded; `1` otherwise (a
|
|
502
|
+
malformed invocation is the usual usage-error exit `2`, before any of this runs). Progress
|
|
503
|
+
(`ok`/`FAIL` per step) goes to stderr as it happens; stdout stays a clean final summary — one
|
|
504
|
+
`PASS`/`FAIL` line per provider plus an overall line — or, with `--json`, the full structured
|
|
505
|
+
result (`hostId`, `repositoryId`, `providers[]`, `teardown` — including any `uncancelledSessionIds`
|
|
506
|
+
— `ok`).
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude
|
|
510
|
+
auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude --provider codex --timeout 600
|
|
511
|
+
```
|
|
512
|
+
|
|
408
513
|
### `auto-harness repo <subcommand>`
|
|
409
514
|
|
|
410
515
|
Repository CRUD, straight against the same routes `auto-harness api` would hit.
|
|
411
516
|
|
|
517
|
+
#### `auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]`
|
|
518
|
+
|
|
519
|
+
`POST /repositories`. The response is the created repository record itself — there is no
|
|
520
|
+
`{ repository }` wrapper, matching the shape `GET /repositories/<id>` returns — so `--json`
|
|
521
|
+
prints it verbatim. Human output is one line, `<id> <name>`, mirroring `repo list`'s per-line
|
|
522
|
+
format. `--default-branch` defaults server-side to `main` when omitted. The server also enforces
|
|
523
|
+
uniqueness and URL/name format; a rejected value comes back as the normal `error:` line (exit 1)
|
|
524
|
+
rather than a client-side re-check.
|
|
525
|
+
|
|
526
|
+
```sh
|
|
527
|
+
auto-harness repo add --name org/repo --url https://github.com/org/repo
|
|
528
|
+
```
|
|
529
|
+
|
|
412
530
|
#### `auto-harness repo list [--limit N] [--cursor C] [--all] [--json]`
|
|
413
531
|
|
|
414
532
|
`GET /repositories`, printing one line per repository (id, name, and status/url when present).
|
|
@@ -490,3 +608,65 @@ KEY=$(auto-harness service-account create --name ci --role operator --print-key)
|
|
|
490
608
|
```sh
|
|
491
609
|
auto-harness service-account rm svc-1
|
|
492
610
|
```
|
|
611
|
+
|
|
612
|
+
### `auto-harness session <subcommand>`
|
|
613
|
+
|
|
614
|
+
Session lifecycle for the operator CLI. `--provider`/`--command` accept either a catalog id or a
|
|
615
|
+
name — see below.
|
|
616
|
+
|
|
617
|
+
#### `auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text> [--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]`
|
|
618
|
+
|
|
619
|
+
`POST /sessions`. Exactly one of `--provider`/`--command` is required. Each accepts either a
|
|
620
|
+
catalog id or a name: the CLI lists the relevant catalog once and checks for an exact id match;
|
|
621
|
+
on a miss it sends the value as `providerName`/`commandName` and lets `createSession()`'s own
|
|
622
|
+
name resolution handle it — so an unresolvable or ambiguous name fails with the same
|
|
623
|
+
`AutoHarnessError` (`UNKNOWN_PROVIDER_NAME`, `AMBIGUOUS_PROVIDER_NAME`, ...) documented above,
|
|
624
|
+
never a separate "unknown id" error. `--timeout` defaults to `600` seconds (matching the
|
|
625
|
+
create-session form's own default) since the server requires it but sets no default itself; the
|
|
626
|
+
server's own ceiling (7 days) is enforced there, not duplicated here.
|
|
627
|
+
|
|
628
|
+
With `--wait`, polls the new session until it reaches a terminal status (`completed`, `failed`,
|
|
629
|
+
`cancelled`, or `timed_out`), printing each status change to **stderr** so stdout stays the final
|
|
630
|
+
session record. `--wait-timeout <seconds>` bounds the wait (default: the session's own
|
|
631
|
+
`--timeout`); on expiry the CLI prints that the session is still running and its id, then exits 1
|
|
632
|
+
— it never cancels the session. Exit 0 only when the session `completed` with `exitCode` exactly
|
|
633
|
+
`0`; every other terminal status, or a wait timeout, exits 1.
|
|
634
|
+
|
|
635
|
+
```sh
|
|
636
|
+
auto-harness session create --repo repo-1 --command claude-print --prompt "Review the diff" --wait
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
#### `auto-harness session get <sessionId> [--json]`
|
|
640
|
+
|
|
641
|
+
`GET /sessions/<id>`, via the library's `getSession()`. Prints one line: id, status, and — only
|
|
642
|
+
when present — `exitCode`, `errorCode`, `errorMessage` (session records use `errorCode`/
|
|
643
|
+
`errorMessage`, never a top-level `error`, and `completedAt`, never `finishedAt`). `--json` prints
|
|
644
|
+
the full record, including `result.summary` when the session set one.
|
|
645
|
+
|
|
646
|
+
```sh
|
|
647
|
+
auto-harness session get session-1
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
#### `auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]`
|
|
651
|
+
|
|
652
|
+
`GET /sessions/<id>/logs` — a bounded page, printed once; this command never loops over every
|
|
653
|
+
page (see `docs/plan.md` invariant 13). Unlike `repo list`/`host list`, the logs endpoint has no
|
|
654
|
+
`nextCursor`; its only continuation knob is `since`, a whole ISO-8601 timestamp (exclusive),
|
|
655
|
+
which this CLI exposes as `--cursor` for a pagination vocabulary consistent with the other list
|
|
656
|
+
commands. A full page (`items.length === limit`, default `1000`) prints a hint to pass the last
|
|
657
|
+
line's own timestamp as the next `--cursor` — which, because `since` excludes an entire
|
|
658
|
+
timestamp rather than one row, also skips any other record sharing that exact timestamp. This is
|
|
659
|
+
the bounded contract the endpoint offers today; there is no exact row cursor over REST.
|
|
660
|
+
|
|
661
|
+
```sh
|
|
662
|
+
auto-harness session logs session-1 --limit 200
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
#### `auto-harness session cancel <sessionId> [--json]`
|
|
666
|
+
|
|
667
|
+
`POST /sessions/<id>/cancel`, via the library's `cancelSession()`. Prints the same one-line
|
|
668
|
+
summary as `session get`.
|
|
669
|
+
|
|
670
|
+
```sh
|
|
671
|
+
auto-harness session cancel session-1
|
|
672
|
+
```
|
package/package.json
CHANGED
package/src/cli/args.js
CHANGED
|
@@ -10,8 +10,18 @@ const REJECTED_API_KEY_MESSAGE =
|
|
|
10
10
|
* every check below — the rejected `--api-key` flag, an unknown flag, a value flag missing its
|
|
11
11
|
* value — only ever sees the flag name. This is what keeps a mistyped `--api-key=<secret>` (or
|
|
12
12
|
* any other `--unknown=<secret>`) from echoing the secret back in a "unknown flag" error.
|
|
13
|
+
*
|
|
14
|
+
* `repeatableFlags` behave like `valueFlags` (same inline-`=`/next-argument value rule, same
|
|
15
|
+
* missing-value error) except every occurrence is collected into an array at `flags[name]`,
|
|
16
|
+
* rather than the last one winning — e.g. `--worktree a=/a --worktree b=/b` yields
|
|
17
|
+
* `flags["--worktree"] === ["a=/a", "b=/b"]`. A flag not given at all is still `undefined`, not
|
|
18
|
+
* an empty array, so callers can tell "none passed" from "passed with zero values" (impossible
|
|
19
|
+
* here, but keeps the convention consistent with `valueFlags`/`booleanFlags`).
|
|
13
20
|
*/
|
|
14
|
-
export function parseFlags(
|
|
21
|
+
export function parseFlags(
|
|
22
|
+
argv,
|
|
23
|
+
{ valueFlags = [], booleanFlags = [], repeatableFlags = [] } = {},
|
|
24
|
+
) {
|
|
15
25
|
const flags = {};
|
|
16
26
|
const positionals = [];
|
|
17
27
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -30,6 +40,12 @@ export function parseFlags(argv, { valueFlags = [], booleanFlags = [] } = {}) {
|
|
|
30
40
|
flags[name] = value;
|
|
31
41
|
continue;
|
|
32
42
|
}
|
|
43
|
+
if (repeatableFlags.includes(name)) {
|
|
44
|
+
const value = inlineValue !== undefined ? inlineValue : argv[(i += 1)];
|
|
45
|
+
if (value === undefined) throw new CliUsageError(`${name} requires a value`);
|
|
46
|
+
(flags[name] ??= []).push(value);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
33
49
|
if (booleanFlags.includes(name)) {
|
|
34
50
|
if (inlineValue !== undefined) throw new CliUsageError(`${name} does not take a value`);
|
|
35
51
|
flags[name] = true;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
import { pathSegment } from "../path-segment.js";
|
|
3
|
+
|
|
4
|
+
const MAX_ATTEMPTS = 3;
|
|
5
|
+
|
|
6
|
+
/** GET the current inventory record for a host — shared by the pre-write "already attached"
|
|
7
|
+
* check, the dry-run preview, and every retry's re-read, so all three see the same document. */
|
|
8
|
+
function getInventory(client, hostId) {
|
|
9
|
+
return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function findAttachedRepository(record, repositoryId) {
|
|
13
|
+
return (record.repositories ?? []).find((repo) => repo.id === repositoryId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Attaches `entry` (an inventory repository entry — see `parseWorktreeFlags` and
|
|
18
|
+
* `host-repo-add.js` for how it is built) to a host's inventory as a safe read-modify-write,
|
|
19
|
+
* mirroring `host-repo-rm.js`'s remove flow: GET the full record, add only the new entry to
|
|
20
|
+
* `repositories`, and PUT back everything else — including `providerAccounts` — exactly as
|
|
21
|
+
* read, keeping the read `version`. A repository already attached to the host is never
|
|
22
|
+
* overwritten; the caller must remove it first. On a 409 (someone else wrote first) this
|
|
23
|
+
* re-reads and re-applies, up to `MAX_ATTEMPTS` PUTs total; any other status is not retried.
|
|
24
|
+
*
|
|
25
|
+
* `{ dryRun: true }` does the same read and "already attached" check but returns before ever
|
|
26
|
+
* writing, so `host repo add --dry-run` and a real attach can never disagree about whether the
|
|
27
|
+
* attach would succeed. Exported (rather than folded into the command) because a later
|
|
28
|
+
* `host smoke` command reuses this exact logic.
|
|
29
|
+
*/
|
|
30
|
+
export async function attachRepository(client, hostId, entry, { dryRun = false } = {}) {
|
|
31
|
+
const record = await getInventory(client, hostId);
|
|
32
|
+
const existing = findAttachedRepository(record, entry.id);
|
|
33
|
+
if (existing) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`repository ${entry.id} is already attached to host ${hostId} at ${existing.path}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (dryRun) {
|
|
39
|
+
return {
|
|
40
|
+
attached: false,
|
|
41
|
+
dryRun: true,
|
|
42
|
+
convergedElsewhere: false,
|
|
43
|
+
hostId,
|
|
44
|
+
repository: entry,
|
|
45
|
+
worktreeIds: entry.worktrees.map((worktree) => worktree.id),
|
|
46
|
+
fromVersion: record.version ?? 0,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return attachWithRetry(client, hostId, entry, record);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function attachWithRetry(client, hostId, entry, record) {
|
|
53
|
+
let currentRecord = record;
|
|
54
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
55
|
+
const fromVersion = currentRecord.version ?? 0;
|
|
56
|
+
const document = {
|
|
57
|
+
...currentRecord,
|
|
58
|
+
repositories: [...(currentRecord.repositories ?? []), entry],
|
|
59
|
+
version: fromVersion,
|
|
60
|
+
};
|
|
61
|
+
try {
|
|
62
|
+
const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
|
|
63
|
+
method: "PUT",
|
|
64
|
+
body: JSON.stringify(document),
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
attached: true,
|
|
68
|
+
dryRun: false,
|
|
69
|
+
convergedElsewhere: false,
|
|
70
|
+
hostId,
|
|
71
|
+
repository: entry,
|
|
72
|
+
worktreeIds: entry.worktrees.map((worktree) => worktree.id),
|
|
73
|
+
fromVersion,
|
|
74
|
+
toVersion: result?.version,
|
|
75
|
+
};
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const conflict = error instanceof AutoHarnessError && error.status === 409;
|
|
78
|
+
if (!conflict) throw error;
|
|
79
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
|
|
82
|
+
{ cause: error },
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
currentRecord = await getInventory(client, hostId);
|
|
86
|
+
const convergence = reconcileConflict(currentRecord, hostId, entry);
|
|
87
|
+
if (convergence) return convergence;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/* v8 ignore next 2 -- the loop above always returns or throws before falling out */
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** After a 409, someone else changed the inventory first. If they attached this same repository
|
|
95
|
+
* at the same path, that is the outcome this call wanted — report convergence rather than
|
|
96
|
+
* erroring, exactly like `host repo rm`'s "already removed by another writer" case. A different
|
|
97
|
+
* path is a real conflict: report it using the record now on the server, not our own intent, so
|
|
98
|
+
* the error names what is actually attached. Neither case retries the loop again. */
|
|
99
|
+
function reconcileConflict(currentRecord, hostId, entry) {
|
|
100
|
+
const existing = findAttachedRepository(currentRecord, entry.id);
|
|
101
|
+
if (!existing) return undefined;
|
|
102
|
+
if (existing.path !== entry.path) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`repository ${entry.id} was attached to host ${hostId} at ${existing.path} by another ` +
|
|
105
|
+
"writer while this command was adding it",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
attached: true,
|
|
110
|
+
dryRun: false,
|
|
111
|
+
convergedElsewhere: true,
|
|
112
|
+
hostId,
|
|
113
|
+
repository: existing,
|
|
114
|
+
worktreeIds: (existing.worktrees ?? []).map((worktree) => worktree.id),
|
|
115
|
+
toVersion: currentRecord.version,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
import { pathSegment } from "../path-segment.js";
|
|
3
|
+
|
|
4
|
+
const MAX_ATTEMPTS = 3;
|
|
5
|
+
|
|
6
|
+
/** GET the current inventory record for a host — shared by the pre-write extraction, the
|
|
7
|
+
* dry-run preview, and every retry's re-read, so all three see the same document. */
|
|
8
|
+
function getInventory(client, hostId) {
|
|
9
|
+
return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Finds the repository by id. By default throws (exit 1) listing what is actually attached;
|
|
13
|
+
* pass `required: false` to get `null` instead — used on a retry's re-read, where the
|
|
14
|
+
* repository being gone already means someone else's write reached the same goal. */
|
|
15
|
+
function extractRepository(record, hostId, repositoryId, { required = true } = {}) {
|
|
16
|
+
const repositories = record.repositories ?? [];
|
|
17
|
+
const repository = repositories.find((repo) => repo.id === repositoryId);
|
|
18
|
+
if (!repository) {
|
|
19
|
+
if (!required) return null;
|
|
20
|
+
const attached = repositories.map((repo) => repo.id);
|
|
21
|
+
throw new Error(
|
|
22
|
+
`repository ${repositoryId} is not attached to host ${hostId}; attached repositories: ` +
|
|
23
|
+
(attached.length > 0 ? attached.join(", ") : "(none)"),
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const worktreeIds = (repository.worktrees ?? []).map((worktree) => worktree.id);
|
|
27
|
+
const remaining = repositories.filter((repo) => repo.id !== repositoryId);
|
|
28
|
+
return { repository, worktreeIds, remaining };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detaches one repository from a host's inventory as a safe read-modify-write, mirroring
|
|
33
|
+
* `attach-repository.js`'s attach flow: GET the full record, remove only the target repository
|
|
34
|
+
* (its worktrees go with it — that is a projection of the repository, not a separate thing to
|
|
35
|
+
* delete), and PUT back everything else — including `providerAccounts` — exactly as read,
|
|
36
|
+
* keeping the read `version`. On a 409 (someone else wrote first) this re-reads and re-applies,
|
|
37
|
+
* up to `MAX_ATTEMPTS` PUTs total; any other status is not retried.
|
|
38
|
+
*
|
|
39
|
+
* `{ dryRun: true }` does the same read and lookup but returns before ever writing, so
|
|
40
|
+
* `host repo rm --dry-run` and a real detach can never disagree about what would be removed.
|
|
41
|
+
* Exported (rather than folded into the command) because `host smoke` reuses this exact logic
|
|
42
|
+
* for its own teardown.
|
|
43
|
+
*/
|
|
44
|
+
export async function detachRepository(client, hostId, repositoryId, { dryRun = false } = {}) {
|
|
45
|
+
const record = await getInventory(client, hostId);
|
|
46
|
+
const removal = extractRepository(record, hostId, repositoryId);
|
|
47
|
+
if (dryRun) {
|
|
48
|
+
return {
|
|
49
|
+
detached: false,
|
|
50
|
+
dryRun: true,
|
|
51
|
+
convergedElsewhere: false,
|
|
52
|
+
hostId,
|
|
53
|
+
repository: removal.repository,
|
|
54
|
+
worktreeIds: removal.worktreeIds,
|
|
55
|
+
fromVersion: record.version ?? 0,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return removeWithRetry(client, hostId, repositoryId, record, removal);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function removeWithRetry(client, hostId, repositoryId, record, removal) {
|
|
62
|
+
let currentRecord = record;
|
|
63
|
+
let current = removal;
|
|
64
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
65
|
+
const fromVersion = currentRecord.version ?? 0;
|
|
66
|
+
const document = { ...currentRecord, repositories: current.remaining, version: fromVersion };
|
|
67
|
+
try {
|
|
68
|
+
const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
|
|
69
|
+
method: "PUT",
|
|
70
|
+
body: JSON.stringify(document),
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
detached: true,
|
|
74
|
+
dryRun: false,
|
|
75
|
+
convergedElsewhere: false,
|
|
76
|
+
hostId,
|
|
77
|
+
repository: current.repository,
|
|
78
|
+
worktreeIds: current.worktreeIds,
|
|
79
|
+
fromVersion,
|
|
80
|
+
toVersion: result?.version,
|
|
81
|
+
};
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const conflict = error instanceof AutoHarnessError && error.status === 409;
|
|
84
|
+
if (!conflict) throw error;
|
|
85
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
|
|
88
|
+
{ cause: error },
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
currentRecord = await getInventory(client, hostId);
|
|
92
|
+
const next = extractRepository(currentRecord, hostId, repositoryId, { required: false });
|
|
93
|
+
if (!next) {
|
|
94
|
+
return {
|
|
95
|
+
detached: true,
|
|
96
|
+
dryRun: false,
|
|
97
|
+
convergedElsewhere: true,
|
|
98
|
+
hostId,
|
|
99
|
+
repository: current.repository,
|
|
100
|
+
worktreeIds: current.worktreeIds,
|
|
101
|
+
toVersion: currentRecord.version,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
current = next;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/* v8 ignore next 2 -- the loop above always returns or throws before falling out */
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
import { parseFlags } from "../args.js";
|
|
3
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
4
|
+
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
5
|
+
import { pathSegment } from "../path-segment.js";
|
|
6
|
+
import { attachRepository } from "./attach-repository.js";
|
|
7
|
+
import { parseWorktreeFlags } from "./parse-worktree-flag.js";
|
|
8
|
+
|
|
9
|
+
const USAGE =
|
|
10
|
+
"usage: auto-harness host repo add <hostId> <repositoryId> --path <path> " +
|
|
11
|
+
"[--worktree <id>=<path>]... [--default-branch <branch>] [--dry-run] [--json]";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Attaches an existing repository to a host's inventory — the counterpart of `host repo rm`.
|
|
15
|
+
* A thin wrapper: flag parsing and presentation live here, but the actual read-modify-write
|
|
16
|
+
* lives in `attachRepository` (its own module, reused by a later `host smoke` command).
|
|
17
|
+
*/
|
|
18
|
+
export async function runHostRepoAdd(argv, io) {
|
|
19
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
20
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--path", "--default-branch"],
|
|
21
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--dry-run", "--json"],
|
|
22
|
+
repeatableFlags: ["--worktree"],
|
|
23
|
+
});
|
|
24
|
+
const [hostId, repositoryId] = positionals;
|
|
25
|
+
if (!hostId || !repositoryId || positionals.length > 2 || !flags["--path"]) {
|
|
26
|
+
throw new CliUsageError(USAGE);
|
|
27
|
+
}
|
|
28
|
+
pathSegment(hostId, "hostId"); // validate before createClient, which may log in
|
|
29
|
+
pathSegment(repositoryId, "repositoryId");
|
|
30
|
+
const worktrees = parseWorktreeFlags(flags["--worktree"] ?? []);
|
|
31
|
+
const client = await createClient(flags, io);
|
|
32
|
+
const repository = await getRepositoryOrFail(client, repositoryId);
|
|
33
|
+
const entry = {
|
|
34
|
+
id: repositoryId,
|
|
35
|
+
path: flags["--path"],
|
|
36
|
+
defaultBranch: flags["--default-branch"] ?? repository.defaultBranch,
|
|
37
|
+
worktrees,
|
|
38
|
+
};
|
|
39
|
+
const result = await attachRepository(client, hostId, entry, {
|
|
40
|
+
dryRun: Boolean(flags["--dry-run"]),
|
|
41
|
+
});
|
|
42
|
+
printResult(io, flags, result);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A 404 here means the id is simply wrong — a clearer message than the generic `error: ...
|
|
47
|
+
* (HTTP 404, NOT_FOUND)` line `reportError` would otherwise print. Any other status (a scoped
|
|
48
|
+
* key that cannot see this repository, a transient failure) is left to the normal error path. */
|
|
49
|
+
async function getRepositoryOrFail(client, repositoryId) {
|
|
50
|
+
try {
|
|
51
|
+
return await client.request(`/repositories/${pathSegment(repositoryId, "repositoryId")}`);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error instanceof AutoHarnessError && error.status === 404) {
|
|
54
|
+
throw new Error(`repository ${repositoryId} not found`, { cause: error });
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function printResult(io, flags, info) {
|
|
61
|
+
if (flags["--json"]) {
|
|
62
|
+
io.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (info.convergedElsewhere) {
|
|
66
|
+
io.stdout.write(
|
|
67
|
+
`repository ${info.repository.id} was already attached to host ${info.hostId} by ` +
|
|
68
|
+
`another writer at the same path (now at version ${info.toVersion})\n`,
|
|
69
|
+
);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const verb = info.dryRun ? "would attach" : "attached";
|
|
73
|
+
const lines = [
|
|
74
|
+
`${verb} repository ${info.repository.id} (${info.repository.path}) to host ${info.hostId}`,
|
|
75
|
+
];
|
|
76
|
+
if (info.worktreeIds.length > 0) {
|
|
77
|
+
lines.push(` worktrees: ${info.worktreeIds.join(", ")}`);
|
|
78
|
+
}
|
|
79
|
+
if (!info.dryRun) lines.push(`version ${info.fromVersion} → ${info.toVersion}`);
|
|
80
|
+
io.stdout.write(`${lines.join("\n")}\n`);
|
|
81
|
+
}
|
|
@@ -1,19 +1,15 @@
|
|
|
1
|
-
import { AutoHarnessError } from "../../index.js";
|
|
2
1
|
import { parseFlags } from "../args.js";
|
|
3
2
|
import { CliUsageError } from "../cli-errors.js";
|
|
4
3
|
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
5
4
|
import { pathSegment } from "../path-segment.js";
|
|
5
|
+
import { detachRepository } from "./detach-repository.js";
|
|
6
6
|
|
|
7
7
|
const USAGE = "usage: auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]";
|
|
8
|
-
const MAX_ATTEMPTS = 3;
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* Detaches one repository from a host's inventory
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `providerAccounts` — exactly as read, with the read `version` kept. On a 409 (someone else
|
|
15
|
-
* wrote first) it re-reads and re-applies, up to `MAX_ATTEMPTS` PUTs total; any other status is
|
|
16
|
-
* not retried.
|
|
10
|
+
* Detaches one repository from a host's inventory — the counterpart of `host repo add`. A thin
|
|
11
|
+
* wrapper: flag parsing and presentation live here, but the actual safe read-modify-write lives
|
|
12
|
+
* in `detachRepository` (its own module, reused by `host smoke`'s teardown).
|
|
17
13
|
*/
|
|
18
14
|
export async function runHostRepoRm(argv, io) {
|
|
19
15
|
const { flags, positionals } = parseFlags(argv, {
|
|
@@ -24,84 +20,11 @@ export async function runHostRepoRm(argv, io) {
|
|
|
24
20
|
if (!hostId || !repositoryId || positionals.length > 2) throw new CliUsageError(USAGE);
|
|
25
21
|
pathSegment(hostId, "hostId"); // validate before createClient, which may log in
|
|
26
22
|
const client = await createClient(flags, io);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
return removeWithRetry(client, io, flags, hostId, repositoryId, record, removal);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function getInventory(client, hostId) {
|
|
37
|
-
return client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Finds the repository by id. By default throws (exit 1) listing what is actually attached;
|
|
41
|
-
* pass `required: false` to get `null` instead — used on a retry's re-read, where the
|
|
42
|
-
* repository being gone already means someone else's write reached the same goal. */
|
|
43
|
-
function extractRepository(record, hostId, repositoryId, { required = true } = {}) {
|
|
44
|
-
const repositories = record.repositories ?? [];
|
|
45
|
-
const repository = repositories.find((repo) => repo.id === repositoryId);
|
|
46
|
-
if (!repository) {
|
|
47
|
-
if (!required) return null;
|
|
48
|
-
const attached = repositories.map((repo) => repo.id);
|
|
49
|
-
throw new Error(
|
|
50
|
-
`repository ${repositoryId} is not attached to host ${hostId}; attached repositories: ` +
|
|
51
|
-
(attached.length > 0 ? attached.join(", ") : "(none)"),
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
const worktreeIds = (repository.worktrees ?? []).map((worktree) => worktree.id);
|
|
55
|
-
const remaining = repositories.filter((repo) => repo.id !== repositoryId);
|
|
56
|
-
return { repository, worktreeIds, remaining };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
async function removeWithRetry(client, io, flags, hostId, repositoryId, record, removal) {
|
|
60
|
-
let currentRecord = record;
|
|
61
|
-
let current = removal;
|
|
62
|
-
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
63
|
-
const fromVersion = currentRecord.version ?? 0;
|
|
64
|
-
const document = { ...currentRecord, repositories: current.remaining, version: fromVersion };
|
|
65
|
-
try {
|
|
66
|
-
const result = await client.request(`/hosts/${pathSegment(hostId, "hostId")}/inventory`, {
|
|
67
|
-
method: "PUT",
|
|
68
|
-
body: JSON.stringify(document),
|
|
69
|
-
});
|
|
70
|
-
printResult(io, flags, {
|
|
71
|
-
dryRun: false,
|
|
72
|
-
hostId,
|
|
73
|
-
repository: current.repository,
|
|
74
|
-
worktreeIds: current.worktreeIds,
|
|
75
|
-
fromVersion,
|
|
76
|
-
toVersion: result?.version,
|
|
77
|
-
});
|
|
78
|
-
return 0;
|
|
79
|
-
} catch (error) {
|
|
80
|
-
const conflict = error instanceof AutoHarnessError && error.status === 409;
|
|
81
|
-
if (!conflict) throw error;
|
|
82
|
-
if (attempt === MAX_ATTEMPTS) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
`inventory for host ${hostId} kept changing; gave up after ${MAX_ATTEMPTS} attempts`,
|
|
85
|
-
{ cause: error },
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
currentRecord = await getInventory(client, hostId);
|
|
89
|
-
const next = extractRepository(currentRecord, hostId, repositoryId, { required: false });
|
|
90
|
-
if (!next) {
|
|
91
|
-
printResult(io, flags, {
|
|
92
|
-
convergedElsewhere: true,
|
|
93
|
-
hostId,
|
|
94
|
-
repository: current.repository,
|
|
95
|
-
worktreeIds: current.worktreeIds,
|
|
96
|
-
toVersion: currentRecord.version,
|
|
97
|
-
});
|
|
98
|
-
return 0;
|
|
99
|
-
}
|
|
100
|
-
current = next;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
/* v8 ignore next 2 -- the loop above always returns or throws before falling out */
|
|
104
|
-
return 1;
|
|
23
|
+
const result = await detachRepository(client, hostId, repositoryId, {
|
|
24
|
+
dryRun: Boolean(flags["--dry-run"]),
|
|
25
|
+
});
|
|
26
|
+
printResult(io, flags, result);
|
|
27
|
+
return 0;
|
|
105
28
|
}
|
|
106
29
|
|
|
107
30
|
function printResult(io, flags, info) {
|