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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Dependency-free client for the Auto Harness automation API",
5
5
  "license": "MIT",
6
6
  "repository": {
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(argv, { valueFlags = [], booleanFlags = [] } = {}) {
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 as a safe read-modify-write: GET the full
12
- * record, remove only the target repository (its worktrees go with it that is a projection
13
- * of the repository, not a separate thing to delete), and PUT back everything else — including
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 record = await getInventory(client, hostId);
28
- const removal = extractRepository(record, hostId, repositoryId);
29
- if (flags["--dry-run"]) {
30
- printResult(io, flags, { dryRun: true, hostId, ...removal });
31
- return 0;
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) {