auto-harness-client 0.6.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 +467 -0
- package/package.json +5 -1
- package/src/cli/admin-login.js +71 -0
- package/src/cli/allowlist.js +34 -0
- package/src/cli/args.js +57 -0
- package/src/cli/cli-errors.js +16 -0
- package/src/cli/commands/api.js +91 -0
- package/src/cli/commands/attach-repository.js +117 -0
- package/src/cli/commands/dependency-conflict.js +15 -0
- package/src/cli/commands/detach-repository.js +109 -0
- package/src/cli/commands/doctor.js +164 -0
- package/src/cli/commands/host-drain.js +22 -0
- package/src/cli/commands/host-inventory-get.js +40 -0
- package/src/cli/commands/host-inventory-set.js +66 -0
- package/src/cli/commands/host-inventory.js +15 -0
- package/src/cli/commands/host-list.js +87 -0
- package/src/cli/commands/host-post-action.js +38 -0
- package/src/cli/commands/host-repo-add.js +81 -0
- package/src/cli/commands/host-repo-rm.js +51 -0
- package/src/cli/commands/host-repo.js +16 -0
- package/src/cli/commands/host-resume.js +17 -0
- 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 +31 -0
- package/src/cli/commands/parse-worktree-flag.js +30 -0
- package/src/cli/commands/repo-add.js +38 -0
- package/src/cli/commands/repo-list.js +84 -0
- package/src/cli/commands/repo-rm.js +83 -0
- package/src/cli/commands/repo.js +18 -0
- package/src/cli/commands/service-account-create.js +114 -0
- package/src/cli/commands/service-account-list.js +84 -0
- package/src/cli/commands/service-account-rm.js +48 -0
- package/src/cli/commands/service-account.js +19 -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/commands/whoami.js +22 -0
- package/src/cli/config.js +96 -0
- package/src/cli/index.js +21 -0
- package/src/cli/main.js +71 -0
- package/src/cli/path-segment.js +20 -0
- package/src/cli/read-stdin.js +9 -0
- package/src/cli/report-error.js +34 -0
- package/src/cli/service-account-format.js +24 -0
- package/src/cli/session-format.js +15 -0
- package/src/cli/usage.js +83 -0
- package/src/cli/wait-for-session.js +46 -0
- package/src/errors.js +1 -0
- package/src/index.d.ts +64 -4
- package/src/index.js +76 -30
- package/src/resolve-target.js +29 -13
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { step } from "./host-smoke-format.js";
|
|
2
|
+
import { waitForSmokeSession } from "./host-smoke-poll.js";
|
|
3
|
+
|
|
4
|
+
// A host's cached inventory only refreshes on its own periodic poll (there is no
|
|
5
|
+
// push-on-write) — see host-smoke-provider.js's doc comment for the full explanation. Five
|
|
6
|
+
// attempts of doubling backoff (2s, 4s, 8s, 16s, capped at 16s) covers the daemon's own
|
|
7
|
+
// default 15s poll at least once while staying well inside a generous --timeout.
|
|
8
|
+
const SETUP_FAILURE_RETRY_LIMIT = 5;
|
|
9
|
+
const SETUP_FAILURE_RETRY_BASE_MS = 2_000;
|
|
10
|
+
const SETUP_FAILURE_RETRY_MAX_MS = 16_000;
|
|
11
|
+
|
|
12
|
+
function defaultSleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True for the one specific `setup_failed` shape thrown by
|
|
17
|
+
* `services/host-daemon/src/worktree-manager.ts` when a host's own cached inventory hasn't
|
|
18
|
+
* caught up with a just-attached repository yet. */
|
|
19
|
+
function isUnknownRepositorySetupFailure(session) {
|
|
20
|
+
return (
|
|
21
|
+
session.status === "failed" &&
|
|
22
|
+
session.errorCode === "setup_failed" &&
|
|
23
|
+
typeof session.errorMessage === "string" &&
|
|
24
|
+
session.errorMessage.startsWith("Unknown repository:")
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Creates a session and waits for it, retrying up to `SETUP_FAILURE_RETRY_LIMIT` times — with
|
|
30
|
+
* exponential backoff, bounded by the same overall `timeoutSeconds` deadline as everything
|
|
31
|
+
* else — but only for that one exact, unambiguous failure signature. Every other terminal shape
|
|
32
|
+
* (a real setup/session failure, `usage_limit`, a genuine timeout, a `createSession` rejection)
|
|
33
|
+
* returns immediately, unretried. A `waitForSmokeSession` rejection (a genuine `getSession`
|
|
34
|
+
* failure — network error, 5xx — not a `UsageLimitSignal`, which it already converts) is caught
|
|
35
|
+
* here too: it is not one of the narrow shapes above, and letting it propagate would abort every
|
|
36
|
+
* remaining `--provider` in `host-smoke.js`'s loop and get misreported as a top-level
|
|
37
|
+
* `setupError`. The created session's id is deliberately left in `activeSessionIds` (never
|
|
38
|
+
* removed on this path) so `teardownSmoke`'s own safety net cancels it. Resolves to
|
|
39
|
+
* `{ kind: "create_failed", message }`, `{ kind: "session_wait_failed", sessionId, message }`, or
|
|
40
|
+
* `{ kind, session, sessionId }` where `kind` is `waitForSmokeSession`'s own
|
|
41
|
+
* `"usage_limit" | "timeout" | "terminal"`.
|
|
42
|
+
*/
|
|
43
|
+
export async function runSessionAttempts({
|
|
44
|
+
client,
|
|
45
|
+
io,
|
|
46
|
+
repositoryId,
|
|
47
|
+
providerRef,
|
|
48
|
+
target,
|
|
49
|
+
marker,
|
|
50
|
+
timeoutSeconds,
|
|
51
|
+
activeSessionIds,
|
|
52
|
+
sleep = defaultSleep,
|
|
53
|
+
now = Date.now,
|
|
54
|
+
intervalMs,
|
|
55
|
+
}) {
|
|
56
|
+
const deadline = now() + timeoutSeconds * 1000;
|
|
57
|
+
for (let attempt = 1; attempt <= SETUP_FAILURE_RETRY_LIMIT; attempt += 1) {
|
|
58
|
+
let created;
|
|
59
|
+
try {
|
|
60
|
+
created = await client.createSession({
|
|
61
|
+
repositoryId,
|
|
62
|
+
prompt: `Reply with exactly: ${marker}`,
|
|
63
|
+
target,
|
|
64
|
+
timeout: timeoutSeconds,
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return { kind: "create_failed", message: error.message };
|
|
68
|
+
}
|
|
69
|
+
activeSessionIds.add(created.id);
|
|
70
|
+
step(io, true, `provider ${providerRef}: created session ${created.id}`);
|
|
71
|
+
|
|
72
|
+
let outcome;
|
|
73
|
+
try {
|
|
74
|
+
outcome = await waitForSmokeSession(client, created.id, {
|
|
75
|
+
timeoutMs: Math.max(1, deadline - now()),
|
|
76
|
+
intervalMs,
|
|
77
|
+
sleep,
|
|
78
|
+
now,
|
|
79
|
+
onStatus: (status) => io.stderr.write(` session ${created.id}: ${status}\n`),
|
|
80
|
+
});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return { kind: "session_wait_failed", sessionId: created.id, message: error.message };
|
|
83
|
+
}
|
|
84
|
+
if (outcome.kind === "terminal") activeSessionIds.delete(created.id);
|
|
85
|
+
|
|
86
|
+
const retryable =
|
|
87
|
+
outcome.kind === "terminal" && isUnknownRepositorySetupFailure(outcome.session);
|
|
88
|
+
const budgetMs = deadline - now();
|
|
89
|
+
if (!retryable || attempt === SETUP_FAILURE_RETRY_LIMIT || budgetMs <= 0) {
|
|
90
|
+
return { ...outcome, sessionId: created.id };
|
|
91
|
+
}
|
|
92
|
+
io.stderr.write(
|
|
93
|
+
` provider ${providerRef}: host has not picked up the newly attached repository yet ` +
|
|
94
|
+
`(attempt ${attempt}/${SETUP_FAILURE_RETRY_LIMIT}); retrying\n`,
|
|
95
|
+
);
|
|
96
|
+
const backoffMs = Math.min(
|
|
97
|
+
SETUP_FAILURE_RETRY_BASE_MS * 2 ** (attempt - 1),
|
|
98
|
+
SETUP_FAILURE_RETRY_MAX_MS,
|
|
99
|
+
);
|
|
100
|
+
await sleep(Math.min(backoffMs, budgetMs));
|
|
101
|
+
}
|
|
102
|
+
/* v8 ignore next 2 -- the loop above always returns before falling out */
|
|
103
|
+
return { kind: "create_failed", message: "unreachable" };
|
|
104
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
import { pathSegment } from "../path-segment.js";
|
|
3
|
+
import { isDependencyConflict } from "./dependency-conflict.js";
|
|
4
|
+
import { detachRepository } from "./detach-repository.js";
|
|
5
|
+
import { step } from "./host-smoke-format.js";
|
|
6
|
+
|
|
7
|
+
// A dependency 409 here almost always means the worktree/host-inventory projection the delete
|
|
8
|
+
// guard reads (see services/api/src/control-plane-delete-guards.ts) hasn't caught up with the
|
|
9
|
+
// detach write this same teardown just made, or a just-cancelled session is still winding down
|
|
10
|
+
// — a few short retries clear both without a long wait. The same budget also covers a delete
|
|
11
|
+
// request that itself fails transiently (5xx, or a network/timeout error below the HTTP layer) —
|
|
12
|
+
// see `isTransientDeleteFailure` below.
|
|
13
|
+
const DELETE_ATTEMPTS = 5;
|
|
14
|
+
|
|
15
|
+
function defaultSleep(ms) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Cancels every session still tracked as active — a per-provider timeout/usage-limit already
|
|
20
|
+
* cancels its own session and untracks it (see `host-smoke-provider.js`); what is left here is
|
|
21
|
+
* only the safety net for a session whose fate an unexpected exception left unresolved. Never
|
|
22
|
+
* throws: a cancel failure is reported as a step, the id is kept (not deleted) in
|
|
23
|
+
* `activeSessionIds`, and it comes back in `uncancelled` for the caller to report — a 409 means
|
|
24
|
+
* the session raced to terminal on its own, which is fine to drop. */
|
|
25
|
+
async function cancelOutstanding(client, io, activeSessionIds) {
|
|
26
|
+
const cancelled = [];
|
|
27
|
+
const uncancelled = [];
|
|
28
|
+
// Safe to delete the current entry mid-iteration (a Set only skips an entry deleted before
|
|
29
|
+
// it is reached); nothing is added to the set during this loop.
|
|
30
|
+
for (const sessionId of activeSessionIds) {
|
|
31
|
+
try {
|
|
32
|
+
await client.cancelSession(sessionId);
|
|
33
|
+
cancelled.push(sessionId);
|
|
34
|
+
activeSessionIds.delete(sessionId);
|
|
35
|
+
step(io, true, `teardown: cancelled session ${sessionId}`);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof AutoHarnessError && error.status === 409) {
|
|
38
|
+
cancelled.push(sessionId);
|
|
39
|
+
activeSessionIds.delete(sessionId);
|
|
40
|
+
step(io, true, `teardown: session ${sessionId} was already terminal`);
|
|
41
|
+
} else {
|
|
42
|
+
uncancelled.push(sessionId);
|
|
43
|
+
step(io, false, `teardown: cancel session ${sessionId} failed: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { cancelled, uncancelled };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** True for a delete failure worth retrying beyond the existing dependency-409 case: an HTTP 5xx
|
|
51
|
+
* (server-side, likely transient) or a rejection that never reached the HTTP layer at all (a
|
|
52
|
+
* network error, or `AutoHarnessRequestTimeoutError`) — neither is `AutoHarnessError`'s own 4xx
|
|
53
|
+
* shape, so both are retried like a 5xx. A 409 is intentionally *not* one of these: it is the
|
|
54
|
+
* server definitively refusing (still has live dependents), not evidence the request itself may
|
|
55
|
+
* have landed. */
|
|
56
|
+
function isTransientDeleteFailure(error) {
|
|
57
|
+
return !(error instanceof AutoHarnessError) || error.status >= 500;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `DELETE /repositories/<id>`, retrying a dependency 409 or a transient failure
|
|
61
|
+
* (`isTransientDeleteFailure`) a few times with a short backoff (`sleep` injected so tests never
|
|
62
|
+
* really wait). Once a transient failure has actually happened, a later 404 is treated as success
|
|
63
|
+
* — the delete most likely landed and the connection dropped before the response did — rather
|
|
64
|
+
* than as a genuine "nothing to delete" failure, which a 404 with no prior transient failure
|
|
65
|
+
* still is. Any other error, or the last attempt, is returned rather than thrown — teardown must
|
|
66
|
+
* never throw (see its own doc comment). */
|
|
67
|
+
async function deleteRepositoryWithRetry(client, repositoryId, sleep) {
|
|
68
|
+
let sawTransientFailure = false;
|
|
69
|
+
for (let attempt = 1; attempt <= DELETE_ATTEMPTS; attempt += 1) {
|
|
70
|
+
try {
|
|
71
|
+
await client.request(`/repositories/${pathSegment(repositoryId, "repositoryId")}`, {
|
|
72
|
+
method: "DELETE",
|
|
73
|
+
});
|
|
74
|
+
return { ok: true };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const notFound = error instanceof AutoHarnessError && error.status === 404;
|
|
77
|
+
if (sawTransientFailure && notFound) return { ok: true };
|
|
78
|
+
const transient = isTransientDeleteFailure(error);
|
|
79
|
+
if ((!transient && !isDependencyConflict(error)) || attempt === DELETE_ATTEMPTS) {
|
|
80
|
+
return { ok: false, error };
|
|
81
|
+
}
|
|
82
|
+
if (transient) sawTransientFailure = true;
|
|
83
|
+
await sleep(Math.min(250 * attempt, 2_000));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/* v8 ignore next 2 -- the loop above always returns before falling out */
|
|
87
|
+
return { ok: false, error: new Error("unreachable") };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Runs from the caller's `finally`, no matter which earlier step failed or threw. Cancels any
|
|
92
|
+
* session smoke created that never reached a terminal status on its own, detaches the
|
|
93
|
+
* repository only if it was actually attached (an attach that itself threw never wrote
|
|
94
|
+
* anything), then deletes the repository, retrying a dependency 409 or a transient failure a few
|
|
95
|
+
* times. Never throws — a teardown failure is reported through its own `ok: false` result plus a
|
|
96
|
+
* cleanup hint on stderr, so it always composes safely inside a `finally` (an exception thrown
|
|
97
|
+
* from a `finally` would replace whatever error the `try` was already failing with). `ok` is
|
|
98
|
+
* false whenever the repository is left behind (a failed detach or delete) **or** any session
|
|
99
|
+
* failed to cancel — a still-live session blocks the delete guard, so an uncancelled session and
|
|
100
|
+
* a leftover repository often go together, but each is reported (and each gets its own cleanup
|
|
101
|
+
* command) independently of the other.
|
|
102
|
+
*/
|
|
103
|
+
export async function teardownSmoke({
|
|
104
|
+
client,
|
|
105
|
+
io,
|
|
106
|
+
hostId,
|
|
107
|
+
repositoryId,
|
|
108
|
+
attached,
|
|
109
|
+
activeSessionIds,
|
|
110
|
+
sleep = defaultSleep,
|
|
111
|
+
}) {
|
|
112
|
+
const { cancelled: cancelledSessionIds, uncancelled: uncancelledSessionIds } =
|
|
113
|
+
await cancelOutstanding(client, io, activeSessionIds);
|
|
114
|
+
|
|
115
|
+
let detached = false;
|
|
116
|
+
let detachError;
|
|
117
|
+
if (attached) {
|
|
118
|
+
try {
|
|
119
|
+
await detachRepository(client, hostId, repositoryId);
|
|
120
|
+
detached = true;
|
|
121
|
+
step(io, true, `teardown: detached repository ${repositoryId} from host ${hostId}`);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
detachError = error;
|
|
124
|
+
step(io, false, `teardown: detach repository ${repositoryId} failed: ${error.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const deleteResult = await deleteRepositoryWithRetry(client, repositoryId, sleep);
|
|
129
|
+
if (deleteResult.ok) {
|
|
130
|
+
step(io, true, `teardown: deleted repository ${repositoryId}`);
|
|
131
|
+
} else {
|
|
132
|
+
step(
|
|
133
|
+
io,
|
|
134
|
+
false,
|
|
135
|
+
`teardown: delete repository ${repositoryId} failed: ${deleteResult.error.message}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const repositoryLeftover = Boolean(detachError) || !deleteResult.ok;
|
|
140
|
+
const ok = !repositoryLeftover && uncancelledSessionIds.length === 0;
|
|
141
|
+
if (!ok) {
|
|
142
|
+
for (const sessionId of uncancelledSessionIds) {
|
|
143
|
+
io.stderr.write(
|
|
144
|
+
`leftover session ${sessionId} — finish cleanup with:\n` +
|
|
145
|
+
` auto-harness session cancel ${sessionId}\n`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (repositoryLeftover) {
|
|
149
|
+
io.stderr.write(
|
|
150
|
+
`leftover repository ${repositoryId} — finish cleanup with:\n` +
|
|
151
|
+
` auto-harness host repo rm ${hostId} ${repositoryId}\n` +
|
|
152
|
+
` auto-harness repo rm ${repositoryId}\n`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok,
|
|
158
|
+
cancelledSessionIds,
|
|
159
|
+
uncancelledSessionIds,
|
|
160
|
+
detached,
|
|
161
|
+
repositoryDeleted: deleteResult.ok,
|
|
162
|
+
...(repositoryLeftover ? { leftoverRepositoryId: repositoryId } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { parseFlags } from "../args.js";
|
|
4
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
5
|
+
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
6
|
+
import { pathSegment } from "../path-segment.js";
|
|
7
|
+
import { attachRepository } from "./attach-repository.js";
|
|
8
|
+
import { printSummary, step } from "./host-smoke-format.js";
|
|
9
|
+
import { runProviderSmoke } from "./host-smoke-provider.js";
|
|
10
|
+
import { createSmokeRepository, smokeInventoryEntry } from "./host-smoke-repository.js";
|
|
11
|
+
import { teardownSmoke } from "./host-smoke-teardown.js";
|
|
12
|
+
|
|
13
|
+
const USAGE =
|
|
14
|
+
"usage: auto-harness host smoke <hostId> --repo-path <path> --provider <id|name> " +
|
|
15
|
+
"[--provider <id|name>]... [--timeout <seconds>] [--json]";
|
|
16
|
+
|
|
17
|
+
// Copied from MAX_SESSION_TIMEOUT_SECONDS in modules/shared/src/validation.ts (7 days) — this
|
|
18
|
+
// package is dependency-free (no @auto-harness/shared import), so the value is duplicated here
|
|
19
|
+
// rather than imported. Keep in sync if the shared limit ever changes.
|
|
20
|
+
const MAX_SESSION_TIMEOUT_SECONDS = 7 * 24 * 60 * 60;
|
|
21
|
+
|
|
22
|
+
// A real provider turn is a full model round trip through a real host, not the quick one-shot
|
|
23
|
+
// prompt `session create`'s own 600s default assumes — generous, but still well inside the
|
|
24
|
+
// server's own ceiling above.
|
|
25
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
26
|
+
|
|
27
|
+
// Arbitrary, small: keeps the wait responsive without hammering the API (matches session
|
|
28
|
+
// create --wait's own poll interval).
|
|
29
|
+
const WAIT_POLL_INTERVAL_MS = 2_000;
|
|
30
|
+
|
|
31
|
+
function defaultRandomHex(bytes) {
|
|
32
|
+
return randomBytes(bytes).toString("hex");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parsePositiveNumber(value, label) {
|
|
36
|
+
const parsed = Number(value);
|
|
37
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
38
|
+
throw new CliUsageError(`${label} must be a positive number`);
|
|
39
|
+
}
|
|
40
|
+
return parsed;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
45
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--repo-path", "--timeout"],
|
|
46
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
47
|
+
repeatableFlags: ["--provider"],
|
|
48
|
+
});
|
|
49
|
+
const [hostId] = positionals;
|
|
50
|
+
const providers = flags["--provider"] ?? [];
|
|
51
|
+
if (!hostId || positionals.length > 1 || !flags["--repo-path"] || providers.length === 0) {
|
|
52
|
+
throw new CliUsageError(USAGE);
|
|
53
|
+
}
|
|
54
|
+
const timeoutSeconds =
|
|
55
|
+
flags["--timeout"] !== undefined
|
|
56
|
+
? parsePositiveNumber(flags["--timeout"], "--timeout")
|
|
57
|
+
: DEFAULT_TIMEOUT_SECONDS;
|
|
58
|
+
if (timeoutSeconds > MAX_SESSION_TIMEOUT_SECONDS) {
|
|
59
|
+
throw new CliUsageError(`--timeout must be at most ${MAX_SESSION_TIMEOUT_SECONDS} seconds`);
|
|
60
|
+
}
|
|
61
|
+
return { flags, hostId, providers, repoPath: flags["--repo-path"], timeoutSeconds };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `host smoke <hostId> --repo-path <path> --provider <id|name>... [--timeout <seconds>]
|
|
66
|
+
* [--json]` — proves a host can run a real provider-routed session end to end, then cleans up
|
|
67
|
+
* after itself. See `modules/client/README.md` for the full step-by-step contract and the
|
|
68
|
+
* preconditions on `--repo-path` this command cannot verify itself (it is a HOST path; the CLI
|
|
69
|
+
* may run elsewhere).
|
|
70
|
+
*
|
|
71
|
+
* Every step logs `ok`/`FAIL` to stderr as it happens; stdout stays a clean final summary (or,
|
|
72
|
+
* with `--json`, the full structured result). Teardown (`teardownSmoke`) always runs, in
|
|
73
|
+
* `finally`, however far setup or the provider loop got. Exit 0 only when every provider passed
|
|
74
|
+
* *and* teardown itself succeeded; 1 otherwise (a malformed invocation is the normal `CliUsageError`
|
|
75
|
+
* path, exit 2, handled by `main.js` before this function is even called for that failure mode).
|
|
76
|
+
*/
|
|
77
|
+
export async function runHostSmoke(argv, io) {
|
|
78
|
+
const { flags, hostId, providers, repoPath, timeoutSeconds } = parseArgs(argv);
|
|
79
|
+
pathSegment(hostId, "hostId"); // validate before createClient, which may log in
|
|
80
|
+
const client = await createClient(flags, io);
|
|
81
|
+
const randomHex = io.randomHex ?? defaultRandomHex;
|
|
82
|
+
const sleep = io.sleep;
|
|
83
|
+
const now = io.now;
|
|
84
|
+
|
|
85
|
+
const activeSessionIds = new Set();
|
|
86
|
+
let repository;
|
|
87
|
+
let attached = false;
|
|
88
|
+
let setupError;
|
|
89
|
+
let teardownResult;
|
|
90
|
+
const providerResults = [];
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
repository = await createSmokeRepository(client, randomHex);
|
|
94
|
+
step(io, true, `created repository ${repository.id} (${repository.name})`);
|
|
95
|
+
|
|
96
|
+
const entry = smokeInventoryEntry(repository, repoPath);
|
|
97
|
+
await attachRepository(client, hostId, entry);
|
|
98
|
+
attached = true;
|
|
99
|
+
step(io, true, `attached repository ${repository.id} to host ${hostId} at ${repoPath}`);
|
|
100
|
+
|
|
101
|
+
const marker = `AH_SMOKE_${randomHex(8).toUpperCase()}`;
|
|
102
|
+
for (const providerRef of providers) {
|
|
103
|
+
const outcome = await runProviderSmoke({
|
|
104
|
+
client,
|
|
105
|
+
io,
|
|
106
|
+
repositoryId: repository.id,
|
|
107
|
+
providerRef,
|
|
108
|
+
marker,
|
|
109
|
+
timeoutSeconds,
|
|
110
|
+
activeSessionIds,
|
|
111
|
+
sleep,
|
|
112
|
+
now,
|
|
113
|
+
intervalMs: WAIT_POLL_INTERVAL_MS,
|
|
114
|
+
});
|
|
115
|
+
providerResults.push(outcome);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
setupError = error;
|
|
119
|
+
step(io, false, `setup: ${error.message}`);
|
|
120
|
+
} finally {
|
|
121
|
+
teardownResult = repository
|
|
122
|
+
? await teardownSmoke({
|
|
123
|
+
client,
|
|
124
|
+
io,
|
|
125
|
+
hostId,
|
|
126
|
+
repositoryId: repository.id,
|
|
127
|
+
attached,
|
|
128
|
+
activeSessionIds,
|
|
129
|
+
sleep,
|
|
130
|
+
})
|
|
131
|
+
: {
|
|
132
|
+
ok: true,
|
|
133
|
+
cancelledSessionIds: [],
|
|
134
|
+
uncancelledSessionIds: [],
|
|
135
|
+
detached: false,
|
|
136
|
+
repositoryDeleted: false,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const ok = !setupError && providerResults.every((provider) => provider.pass) && teardownResult.ok;
|
|
141
|
+
const result = {
|
|
142
|
+
hostId,
|
|
143
|
+
repositoryId: repository?.id,
|
|
144
|
+
providers: providerResults,
|
|
145
|
+
teardown: teardownResult,
|
|
146
|
+
ok,
|
|
147
|
+
...(setupError ? { setupError: setupError.message } : {}),
|
|
148
|
+
};
|
|
149
|
+
printSummary(io, flags, result);
|
|
150
|
+
return ok ? 0 : 1;
|
|
151
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runHostDrain } from "./host-drain.js";
|
|
3
|
+
import { runHostInventory } from "./host-inventory.js";
|
|
4
|
+
import { runHostList } from "./host-list.js";
|
|
5
|
+
import { runHostRepo } from "./host-repo.js";
|
|
6
|
+
import { runHostResume } from "./host-resume.js";
|
|
7
|
+
import { runHostSmoke } from "./host-smoke.js";
|
|
8
|
+
|
|
9
|
+
const USAGE = `usage: auto-harness host <subcommand> ...
|
|
10
|
+
auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]
|
|
11
|
+
auto-harness host drain <hostId> [--json]
|
|
12
|
+
auto-harness host resume <hostId> [--json]
|
|
13
|
+
auto-harness host inventory get <hostId> [--json]
|
|
14
|
+
auto-harness host inventory set <hostId> --file <path|->
|
|
15
|
+
auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]...
|
|
16
|
+
[--default-branch <branch>] [--dry-run] [--json]
|
|
17
|
+
auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]
|
|
18
|
+
auto-harness host smoke <hostId> --repo-path <path> --provider <id|name>
|
|
19
|
+
[--provider <id|name>]... [--timeout <seconds>] [--json]`;
|
|
20
|
+
|
|
21
|
+
/** Dispatches `host <subcommand>` to its own module — mirrors `main.js`'s own dispatch. */
|
|
22
|
+
export async function runHost(argv, io) {
|
|
23
|
+
const [subcommand, ...rest] = argv;
|
|
24
|
+
if (subcommand === "list") return runHostList(rest, io);
|
|
25
|
+
if (subcommand === "drain") return runHostDrain(rest, io);
|
|
26
|
+
if (subcommand === "resume") return runHostResume(rest, io);
|
|
27
|
+
if (subcommand === "inventory") return runHostInventory(rest, io);
|
|
28
|
+
if (subcommand === "repo") return runHostRepo(rest, io);
|
|
29
|
+
if (subcommand === "smoke") return runHostSmoke(rest, io);
|
|
30
|
+
throw new CliUsageError(USAGE);
|
|
31
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses repeated `--worktree <id>=<path>` values (as collected by `parseFlags`'s
|
|
5
|
+
* `repeatableFlags`) into inventory worktree entries: `{ id, name: id, path, labels: [] }`.
|
|
6
|
+
* `name` mirrors `id` — the server's own slug check on `name` (see
|
|
7
|
+
* `modules/shared/src/host-inventory-parse.ts`) is what actually constrains its shape, so this
|
|
8
|
+
* does not re-validate it. Splits on the *first* `=` so a path containing `=` still parses
|
|
9
|
+
* correctly; an entry with no `=`, an empty id, or an empty path is a usage error (exit 2), as
|
|
10
|
+
* is a repeated id — the server would also reject a duplicate worktree id, but only after a
|
|
11
|
+
* round trip, so catching it here is a strictly better error.
|
|
12
|
+
*/
|
|
13
|
+
export function parseWorktreeFlags(rawValues) {
|
|
14
|
+
const worktrees = [];
|
|
15
|
+
const seenIds = new Set();
|
|
16
|
+
for (const raw of rawValues) {
|
|
17
|
+
const equals = raw.indexOf("=");
|
|
18
|
+
if (equals <= 0 || equals === raw.length - 1) {
|
|
19
|
+
throw new CliUsageError(`--worktree must be <id>=<path>, got: ${raw}`);
|
|
20
|
+
}
|
|
21
|
+
const id = raw.slice(0, equals);
|
|
22
|
+
const path = raw.slice(equals + 1);
|
|
23
|
+
if (seenIds.has(id)) {
|
|
24
|
+
throw new CliUsageError(`--worktree id given more than once: ${id}`);
|
|
25
|
+
}
|
|
26
|
+
seenIds.add(id);
|
|
27
|
+
worktrees.push({ id, name: id, path, labels: [] });
|
|
28
|
+
}
|
|
29
|
+
return worktrees;
|
|
30
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseFlags } from "../args.js";
|
|
2
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
3
|
+
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
4
|
+
|
|
5
|
+
const USAGE =
|
|
6
|
+
"usage: auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `POST /repositories`. The response is the created repository record itself — no `{ repository
|
|
10
|
+
* }` wrapper, matching `GET /repositories/<id>` — so `--json` prints it verbatim. Human output
|
|
11
|
+
* is one line (id, name), mirroring `repo list`'s per-line format.
|
|
12
|
+
*/
|
|
13
|
+
export async function runRepoAdd(argv, io) {
|
|
14
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
15
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--name", "--url", "--default-branch"],
|
|
16
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
17
|
+
});
|
|
18
|
+
if (positionals.length > 0 || !flags["--name"] || !flags["--url"]) {
|
|
19
|
+
throw new CliUsageError(USAGE);
|
|
20
|
+
}
|
|
21
|
+
const client = await createClient(flags, io);
|
|
22
|
+
const repository = await client.request("/repositories", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
body: JSON.stringify({
|
|
25
|
+
name: flags["--name"],
|
|
26
|
+
url: flags["--url"],
|
|
27
|
+
...(flags["--default-branch"] !== undefined
|
|
28
|
+
? { defaultBranch: flags["--default-branch"] }
|
|
29
|
+
: {}),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
if (flags["--json"]) {
|
|
33
|
+
io.stdout.write(`${JSON.stringify(repository, null, 2)}\n`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
io.stdout.write(`${repository.id} ${repository.name}\n`);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { parseFlags } from "../args.js";
|
|
2
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
3
|
+
import { createClient, GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "../config.js";
|
|
4
|
+
|
|
5
|
+
// This repo's list/history invariant forbids unbounded collection of pages at storage, so
|
|
6
|
+
// `--all` stops here and warns rather than following `nextCursor` forever. Mirrors `host list`.
|
|
7
|
+
const MAX_ALL_PAGES = 20;
|
|
8
|
+
|
|
9
|
+
/** `GET /repositories`, paged with `limit`/`cursor`. `--all` follows `nextCursor` itself,
|
|
10
|
+
* capped at `MAX_ALL_PAGES` pages. */
|
|
11
|
+
export async function runRepoList(argv, io) {
|
|
12
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
13
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
|
|
14
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--all", "--json"],
|
|
15
|
+
});
|
|
16
|
+
if (positionals.length > 0) {
|
|
17
|
+
throw new CliUsageError(`repo list takes no arguments; received: ${positionals.join(" ")}`);
|
|
18
|
+
}
|
|
19
|
+
const client = await createClient(flags, io);
|
|
20
|
+
return flags["--all"] ? runListAll(client, flags, io) : runListOnePage(client, flags, io);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildQuery(flags, cursor) {
|
|
24
|
+
const query = new URLSearchParams();
|
|
25
|
+
if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
|
|
26
|
+
if (cursor !== undefined) query.set("cursor", cursor);
|
|
27
|
+
const suffix = query.toString();
|
|
28
|
+
return suffix ? `/repositories?${suffix}` : "/repositories";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function runListOnePage(client, flags, io) {
|
|
32
|
+
const page = await client.request(buildQuery(flags, flags["--cursor"]));
|
|
33
|
+
if (flags["--json"]) {
|
|
34
|
+
io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
io.stdout.write(formatRepoLines(page.items ?? []));
|
|
38
|
+
if (page.nextCursor) {
|
|
39
|
+
io.stdout.write(
|
|
40
|
+
`more repositories available; pass --cursor ${page.nextCursor} to continue (or --all)\n`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function runListAll(client, flags, io) {
|
|
47
|
+
const items = [];
|
|
48
|
+
const seenCursors = new Set();
|
|
49
|
+
let cursor = flags["--cursor"];
|
|
50
|
+
for (let pageCount = 0; pageCount < MAX_ALL_PAGES; pageCount += 1) {
|
|
51
|
+
const page = await client.request(buildQuery(flags, cursor));
|
|
52
|
+
items.push(...(page.items ?? []));
|
|
53
|
+
cursor = page.nextCursor || undefined;
|
|
54
|
+
if (!cursor) break;
|
|
55
|
+
// Mirrors the same guard in AutoHarnessClient#listCatalog: a server repeating a cursor is a
|
|
56
|
+
// bug worth failing loudly on, not something to paper over as "more pages than the cap".
|
|
57
|
+
if (seenCursors.has(cursor)) throw new Error("repeated pagination cursor for /repositories");
|
|
58
|
+
seenCursors.add(cursor);
|
|
59
|
+
}
|
|
60
|
+
if (cursor) {
|
|
61
|
+
io.stderr.write(
|
|
62
|
+
`warning: --all stopped after ${MAX_ALL_PAGES} pages; more repositories remain ` +
|
|
63
|
+
`(nextCursor: ${cursor})\n`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (flags["--json"]) {
|
|
67
|
+
io.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
io.stdout.write(formatRepoLines(items));
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatRepoLines(items) {
|
|
75
|
+
if (items.length === 0) return "(no repositories)\n";
|
|
76
|
+
return `${items.map(formatRepoLine).join("\n")}\n`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatRepoLine(repo) {
|
|
80
|
+
const parts = [repo.id, repo.name];
|
|
81
|
+
if (repo.status) parts.push(repo.status);
|
|
82
|
+
if (repo.url) parts.push(repo.url);
|
|
83
|
+
return parts.join(" ");
|
|
84
|
+
}
|