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,87 @@
|
|
|
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.
|
|
7
|
+
const MAX_ALL_PAGES = 20;
|
|
8
|
+
|
|
9
|
+
/** `GET /hosts`, optionally filtered by `online`/`offline` and paged with `limit`/`cursor`.
|
|
10
|
+
* `--all` follows `nextCursor` itself, capped at `MAX_ALL_PAGES` pages. */
|
|
11
|
+
export async function runHostList(argv, io) {
|
|
12
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
13
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
|
|
14
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--online", "--offline", "--all", "--json"],
|
|
15
|
+
});
|
|
16
|
+
if (positionals.length > 0) {
|
|
17
|
+
throw new CliUsageError(`host list takes no arguments; received: ${positionals.join(" ")}`);
|
|
18
|
+
}
|
|
19
|
+
if (flags["--online"] && flags["--offline"]) {
|
|
20
|
+
throw new CliUsageError("--online and --offline are mutually exclusive");
|
|
21
|
+
}
|
|
22
|
+
const client = await createClient(flags, io);
|
|
23
|
+
return flags["--all"] ? runListAll(client, flags, io) : runListOnePage(client, flags, io);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildQuery(flags, cursor) {
|
|
27
|
+
const query = new URLSearchParams();
|
|
28
|
+
if (flags["--online"]) query.set("online", "online");
|
|
29
|
+
if (flags["--offline"]) query.set("online", "offline");
|
|
30
|
+
if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
|
|
31
|
+
if (cursor !== undefined) query.set("cursor", cursor);
|
|
32
|
+
const suffix = query.toString();
|
|
33
|
+
return suffix ? `/hosts?${suffix}` : "/hosts";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runListOnePage(client, flags, io) {
|
|
37
|
+
const page = await client.request(buildQuery(flags, flags["--cursor"]));
|
|
38
|
+
if (flags["--json"]) {
|
|
39
|
+
io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
io.stdout.write(formatHostLines(page.items ?? []));
|
|
43
|
+
if (page.nextCursor) {
|
|
44
|
+
io.stdout.write(
|
|
45
|
+
`more hosts available; pass --cursor ${page.nextCursor} to continue (or --all)\n`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function runListAll(client, flags, io) {
|
|
52
|
+
const items = [];
|
|
53
|
+
const seenCursors = new Set();
|
|
54
|
+
let cursor = flags["--cursor"];
|
|
55
|
+
for (let pageCount = 0; pageCount < MAX_ALL_PAGES; pageCount += 1) {
|
|
56
|
+
const page = await client.request(buildQuery(flags, cursor));
|
|
57
|
+
items.push(...(page.items ?? []));
|
|
58
|
+
cursor = page.nextCursor || undefined;
|
|
59
|
+
if (!cursor) break;
|
|
60
|
+
// Mirrors the same guard in AutoHarnessClient#listCatalog: a server repeating a cursor is a
|
|
61
|
+
// bug worth failing loudly on, not something to paper over as "more pages than the cap".
|
|
62
|
+
if (seenCursors.has(cursor)) throw new Error("repeated pagination cursor for /hosts");
|
|
63
|
+
seenCursors.add(cursor);
|
|
64
|
+
}
|
|
65
|
+
if (cursor) {
|
|
66
|
+
io.stderr.write(
|
|
67
|
+
`warning: --all stopped after ${MAX_ALL_PAGES} pages; more hosts remain ` +
|
|
68
|
+
`(nextCursor: ${cursor})\n`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (flags["--json"]) {
|
|
72
|
+
io.stdout.write(`${JSON.stringify({ items }, null, 2)}\n`);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
io.stdout.write(formatHostLines(items));
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatHostLines(items) {
|
|
80
|
+
if (items.length === 0) return "(no hosts)\n";
|
|
81
|
+
return `${items
|
|
82
|
+
.map(
|
|
83
|
+
(host) =>
|
|
84
|
+
`${host.hostId} ${host.online ? "online" : "offline"}${host.draining ? " draining" : ""}`,
|
|
85
|
+
)
|
|
86
|
+
.join("\n")}\n`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 { reportError } from "../report-error.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared shape for `host drain` and `host resume`: both POST `{ hostId }` in the body — never
|
|
9
|
+
* the path, see `local-routes-host-drain.ts` — and both can 409 on a host connection race. A
|
|
10
|
+
* 409 is reported here, inside the command, rather than left to `main.js`'s catch: once that
|
|
11
|
+
* catch runs there is no way back into this function to add the trailing retry hint, so this
|
|
12
|
+
* prints the normal error line itself (via `reportError`) and then the hint, in that order.
|
|
13
|
+
* `onSuccess(result, hostId, flags)` writes the success output — it closes over its own `io`
|
|
14
|
+
* rather than taking one here, so it does not shadow this function's `io` parameter.
|
|
15
|
+
*/
|
|
16
|
+
export async function runHostIdPostAction(argv, io, { commandName, path, onSuccess }) {
|
|
17
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
18
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
19
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
20
|
+
});
|
|
21
|
+
const [hostId] = positionals;
|
|
22
|
+
if (!hostId || positionals.length > 1) {
|
|
23
|
+
throw new CliUsageError(`usage: auto-harness host ${commandName} <hostId> [--json]`);
|
|
24
|
+
}
|
|
25
|
+
const client = await createClient(flags, io);
|
|
26
|
+
let result;
|
|
27
|
+
try {
|
|
28
|
+
result = await client.request(path, { method: "POST", body: JSON.stringify({ hostId }) });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
const exitCode = reportError(error, io);
|
|
31
|
+
if (error instanceof AutoHarnessError && error.status === 409) {
|
|
32
|
+
io.stderr.write("hint: the host's connection changed mid-request; retrying is safe\n");
|
|
33
|
+
}
|
|
34
|
+
return exitCode;
|
|
35
|
+
}
|
|
36
|
+
onSuccess(result, hostId, flags);
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
import { pathSegment } from "../path-segment.js";
|
|
5
|
+
import { detachRepository } from "./detach-repository.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]";
|
|
8
|
+
|
|
9
|
+
/**
|
|
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).
|
|
13
|
+
*/
|
|
14
|
+
export async function runHostRepoRm(argv, io) {
|
|
15
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
16
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
17
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--dry-run", "--json"],
|
|
18
|
+
});
|
|
19
|
+
const [hostId, repositoryId] = positionals;
|
|
20
|
+
if (!hostId || !repositoryId || positionals.length > 2) throw new CliUsageError(USAGE);
|
|
21
|
+
pathSegment(hostId, "hostId"); // validate before createClient, which may log in
|
|
22
|
+
const client = await createClient(flags, io);
|
|
23
|
+
const result = await detachRepository(client, hostId, repositoryId, {
|
|
24
|
+
dryRun: Boolean(flags["--dry-run"]),
|
|
25
|
+
});
|
|
26
|
+
printResult(io, flags, result);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function printResult(io, flags, info) {
|
|
31
|
+
if (flags["--json"]) {
|
|
32
|
+
io.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (info.convergedElsewhere) {
|
|
36
|
+
io.stdout.write(
|
|
37
|
+
`repository ${info.repository.id} was already removed from host ${info.hostId} by ` +
|
|
38
|
+
`another writer (now at version ${info.toVersion})\n`,
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const verb = info.dryRun ? "would remove" : "removed";
|
|
43
|
+
const lines = [
|
|
44
|
+
`${verb} repository ${info.repository.id} (${info.repository.path}) from host ${info.hostId}`,
|
|
45
|
+
];
|
|
46
|
+
if (info.worktreeIds.length > 0) {
|
|
47
|
+
lines.push(` worktrees removed with it: ${info.worktreeIds.join(", ")}`);
|
|
48
|
+
}
|
|
49
|
+
if (!info.dryRun) lines.push(`version ${info.fromVersion} → ${info.toVersion}`);
|
|
50
|
+
io.stdout.write(`${lines.join("\n")}\n`);
|
|
51
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runHostRepoAdd } from "./host-repo-add.js";
|
|
3
|
+
import { runHostRepoRm } from "./host-repo-rm.js";
|
|
4
|
+
|
|
5
|
+
const USAGE = `usage: auto-harness host repo <subcommand> ...
|
|
6
|
+
auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]...
|
|
7
|
+
[--default-branch <branch>] [--dry-run] [--json]
|
|
8
|
+
auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]`;
|
|
9
|
+
|
|
10
|
+
/** Dispatches `host repo <add|rm>`. */
|
|
11
|
+
export async function runHostRepo(argv, io) {
|
|
12
|
+
const [action, ...rest] = argv;
|
|
13
|
+
if (action === "add") return runHostRepoAdd(rest, io);
|
|
14
|
+
if (action === "rm") return runHostRepoRm(rest, io);
|
|
15
|
+
throw new CliUsageError(USAGE);
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { runHostIdPostAction } from "./host-post-action.js";
|
|
2
|
+
|
|
3
|
+
/** `POST /hosts/resume` with `{ hostId }` in the body. Idempotent: safe on a host that is not
|
|
4
|
+
* currently draining. */
|
|
5
|
+
export async function runHostResume(argv, io) {
|
|
6
|
+
return runHostIdPostAction(argv, io, {
|
|
7
|
+
commandName: "resume",
|
|
8
|
+
path: "/hosts/resume",
|
|
9
|
+
onSuccess: (result, hostId, flags) => {
|
|
10
|
+
if (flags["--json"]) {
|
|
11
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
io.stdout.write(`host ${hostId} resumed (safe to run even if it was not draining)\n`);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One progress line per step, as it happens — always stderr, so stdout stays a clean final
|
|
2
|
+
* summary (mirrors `session create --wait`'s status-change lines going to stderr). */
|
|
3
|
+
export function step(io, ok, message) {
|
|
4
|
+
io.stderr.write(`${ok ? "ok" : "FAIL"} ${message}\n`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The clean final summary on stdout: `--json` prints the full structured `result` verbatim;
|
|
9
|
+
* otherwise one `PASS`/`FAIL` line per provider (only ever empty when repo create/attach itself
|
|
10
|
+
* failed, in which case `result.setupError` is printed instead), then one overall line.
|
|
11
|
+
*/
|
|
12
|
+
export function printSummary(io, flags, result) {
|
|
13
|
+
if (flags["--json"]) {
|
|
14
|
+
io.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (result.setupError) {
|
|
18
|
+
io.stdout.write(`FAIL setup: ${result.setupError}\n`);
|
|
19
|
+
}
|
|
20
|
+
for (const provider of result.providers) {
|
|
21
|
+
const detail = provider.message ? `: ${provider.message}` : "";
|
|
22
|
+
io.stdout.write(`${provider.pass ? "PASS" : "FAIL"} ${provider.provider}${detail}\n`);
|
|
23
|
+
}
|
|
24
|
+
const passed = result.providers.filter((provider) => provider.pass).length;
|
|
25
|
+
if (result.providers.length > 0) {
|
|
26
|
+
io.stdout.write(`${passed}/${result.providers.length} providers passed\n`);
|
|
27
|
+
}
|
|
28
|
+
io.stdout.write(`teardown ${result.teardown.ok ? "ok" : "FAILED"}\n`);
|
|
29
|
+
io.stdout.write(`host smoke ${result.ok ? "PASSED" : "FAILED"} for host ${result.hostId}\n`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { waitForSession } from "../wait-for-session.js";
|
|
2
|
+
|
|
3
|
+
/** Thrown from the wrapped `getSession` below to escape `waitForSession`'s loop the instant a
|
|
4
|
+
* usage-limit requeue is observed, rather than waiting out the rest of `timeoutMs`. */
|
|
5
|
+
class UsageLimitSignal extends Error {
|
|
6
|
+
constructor(session) {
|
|
7
|
+
super("usage_limit");
|
|
8
|
+
this.session = session;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `client.getSession` wrapped to check *every* poll for `errorCode === "usage_limit"`, not
|
|
13
|
+
* only a status *change* — see `waitForSmokeSession`'s doc comment for why that distinction
|
|
14
|
+
* matters here. */
|
|
15
|
+
function usageLimitWatchedClient(client) {
|
|
16
|
+
return {
|
|
17
|
+
getSession: async (id) => {
|
|
18
|
+
const session = await client.getSession(id);
|
|
19
|
+
if (session.errorCode === "usage_limit") throw new UsageLimitSignal(session);
|
|
20
|
+
return session;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Wraps `waitForSession` so `host smoke` fails a provider fast when the control plane reports
|
|
27
|
+
* `errorCode: "usage_limit"`, instead of sitting out the whole `--timeout`.
|
|
28
|
+
* `session-transition-planner.ts`'s `planUsageLimit()` *requeues* a usage-limited session
|
|
29
|
+
* (status goes back to `"queued"`, cooldown applied to the account) rather than failing it
|
|
30
|
+
* outright, so a plain wait-for-terminal-status loop would never notice until the timeout
|
|
31
|
+
* elapsed. This is checked on every single poll, not on `waitForSession`'s own `onStatus`
|
|
32
|
+
* (which only fires on a status *change*) — a session that never even reaches `"running"`
|
|
33
|
+
* (requeued immediately, or its account was already cooling down when created) gets
|
|
34
|
+
* `errorCode` set with no status transition to hang the check off of.
|
|
35
|
+
*
|
|
36
|
+
* Throwing from the wrapped `getSession` — rather than from `onStatus` — rides a documented
|
|
37
|
+
* part of `wait-for-session.js`'s own contract ("A `getSession` rejection ... propagates
|
|
38
|
+
* as-is"), instead of relying on the undocumented fact that `onStatus` happens to run outside
|
|
39
|
+
* a try/catch. `onStatus` itself is passed through untouched, still only for status-change
|
|
40
|
+
* narration (e.g. to stderr).
|
|
41
|
+
*
|
|
42
|
+
* Resolves to `{ kind: "usage_limit", session }`, `{ kind: "timeout", session }`, or
|
|
43
|
+
* `{ kind: "terminal", session }` — never rejects for a usage-limit signal; a genuine
|
|
44
|
+
* `getSession` failure (network/API error) still propagates, exactly as `waitForSession` docs.
|
|
45
|
+
*/
|
|
46
|
+
export async function waitForSmokeSession(
|
|
47
|
+
client,
|
|
48
|
+
sessionId,
|
|
49
|
+
{ timeoutMs, intervalMs, sleep, now, onStatus },
|
|
50
|
+
) {
|
|
51
|
+
try {
|
|
52
|
+
const result = await waitForSession(usageLimitWatchedClient(client), sessionId, {
|
|
53
|
+
timeoutMs,
|
|
54
|
+
intervalMs,
|
|
55
|
+
sleep,
|
|
56
|
+
now,
|
|
57
|
+
onStatus,
|
|
58
|
+
});
|
|
59
|
+
return { kind: result.timedOut ? "timeout" : "terminal", session: result.session };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error instanceof UsageLimitSignal) return { kind: "usage_limit", session: error.session };
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One-line diagnosis for a timed-out provider, keyed off the last observed status — see
|
|
67
|
+
* `host-smoke.js`'s usage text and the README for the fuller explanation. */
|
|
68
|
+
export function timeoutHint(lastStatus) {
|
|
69
|
+
if (lastStatus === "queued") {
|
|
70
|
+
return (
|
|
71
|
+
"session stayed queued — check that an online host advertises a ready execution " +
|
|
72
|
+
"profile for this provider's account (HARNESS_EXECUTION_PROFILES), and that something " +
|
|
73
|
+
"is running the scheduler (POST /scheduler/assign)"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return `session was still ${lastStatus} when the timeout elapsed`;
|
|
77
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { AutoHarnessError } from "../../index.js";
|
|
2
|
+
import { pathSegment } from "../path-segment.js";
|
|
3
|
+
import { resolveSessionTarget } from "./session-target.js";
|
|
4
|
+
import { step } from "./host-smoke-format.js";
|
|
5
|
+
import { timeoutHint } from "./host-smoke-poll.js";
|
|
6
|
+
import { runSessionAttempts } from "./host-smoke-session-attempt.js";
|
|
7
|
+
|
|
8
|
+
/** Cancels a session smoke created and, either way, removes it from the shared
|
|
9
|
+
* `activeSessionIds` bookkeeping the top-level teardown uses as its safety net — unless the
|
|
10
|
+
* cancel call itself fails for a reason other than "already terminal" (409), in which case the
|
|
11
|
+
* id is deliberately left in the set so `teardownSmoke` retries it at the end of the run. */
|
|
12
|
+
async function cancelForOutcome(client, io, sessionId, activeSessionIds) {
|
|
13
|
+
try {
|
|
14
|
+
await client.cancelSession(sessionId);
|
|
15
|
+
activeSessionIds.delete(sessionId);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error instanceof AutoHarnessError && error.status === 409) {
|
|
18
|
+
activeSessionIds.delete(sessionId); // raced to terminal on its own; nothing to cancel
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
io.stderr.write(
|
|
22
|
+
` session ${sessionId}: cancel failed (${error.message}); teardown will retry it\n`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function fetchStdout(client, sessionId) {
|
|
28
|
+
const page = await client.request(`/sessions/${pathSegment(sessionId, "sessionId")}/logs`);
|
|
29
|
+
return (page.items ?? [])
|
|
30
|
+
.filter((item) => item.stream === "stdout")
|
|
31
|
+
.map((item) => item.content)
|
|
32
|
+
.join("\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runs one `--provider`'s end-to-end smoke session: resolve the target, then hand off to
|
|
37
|
+
* `runSessionAttempts` (create + wait, with a narrow, bounded retry for one specific
|
|
38
|
+
* "host hasn't caught up with the repo it just attached" failure — a host's cached inventory
|
|
39
|
+
* only refreshes on its own periodic poll, there is no push-on-write, and `host smoke` attaches
|
|
40
|
+
* its own throwaway repository immediately before creating a session against it, which races
|
|
41
|
+
* that poll in any real deployment, not only in a fast test), then check its logs. Always
|
|
42
|
+
* resolves to an outcome object — `{ provider, pass, ... }` — never throws or rejects, so a
|
|
43
|
+
* bad `--provider` value or a mid-poll network blip fails only *this* provider rather than
|
|
44
|
+
* aborting the ones after it; `host-smoke.js`'s loop relies on that to keep going.
|
|
45
|
+
*/
|
|
46
|
+
export async function runProviderSmoke({
|
|
47
|
+
client,
|
|
48
|
+
io,
|
|
49
|
+
repositoryId,
|
|
50
|
+
providerRef,
|
|
51
|
+
marker,
|
|
52
|
+
timeoutSeconds,
|
|
53
|
+
activeSessionIds,
|
|
54
|
+
sleep,
|
|
55
|
+
now,
|
|
56
|
+
intervalMs,
|
|
57
|
+
}) {
|
|
58
|
+
let target;
|
|
59
|
+
try {
|
|
60
|
+
target = await resolveSessionTarget(client, { "--provider": providerRef });
|
|
61
|
+
} catch (error) {
|
|
62
|
+
step(io, false, `provider ${providerRef}: could not resolve target: ${error.message}`);
|
|
63
|
+
return {
|
|
64
|
+
provider: providerRef,
|
|
65
|
+
pass: false,
|
|
66
|
+
reason: "target_resolution_failed",
|
|
67
|
+
message: error.message,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const outcome = await runSessionAttempts({
|
|
72
|
+
client,
|
|
73
|
+
io,
|
|
74
|
+
repositoryId,
|
|
75
|
+
providerRef,
|
|
76
|
+
target,
|
|
77
|
+
marker,
|
|
78
|
+
timeoutSeconds,
|
|
79
|
+
activeSessionIds,
|
|
80
|
+
sleep,
|
|
81
|
+
now,
|
|
82
|
+
intervalMs,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (outcome.kind === "create_failed") {
|
|
86
|
+
step(io, false, `provider ${providerRef}: create session failed: ${outcome.message}`);
|
|
87
|
+
return {
|
|
88
|
+
provider: providerRef,
|
|
89
|
+
pass: false,
|
|
90
|
+
reason: "create_failed",
|
|
91
|
+
message: outcome.message,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (outcome.kind === "session_wait_failed") {
|
|
95
|
+
step(io, false, `provider ${providerRef}: session wait failed: ${outcome.message}`);
|
|
96
|
+
return {
|
|
97
|
+
provider: providerRef,
|
|
98
|
+
pass: false,
|
|
99
|
+
reason: "session_wait_failed",
|
|
100
|
+
sessionId: outcome.sessionId,
|
|
101
|
+
message: outcome.message,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (outcome.kind === "usage_limit") {
|
|
105
|
+
await cancelForOutcome(client, io, outcome.sessionId, activeSessionIds);
|
|
106
|
+
const message = "provider account hit its usage limit";
|
|
107
|
+
step(io, false, `provider ${providerRef}: ${message}`);
|
|
108
|
+
return {
|
|
109
|
+
provider: providerRef,
|
|
110
|
+
pass: false,
|
|
111
|
+
reason: "usage_limit",
|
|
112
|
+
sessionId: outcome.sessionId,
|
|
113
|
+
message,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (outcome.kind === "timeout") {
|
|
117
|
+
await cancelForOutcome(client, io, outcome.sessionId, activeSessionIds);
|
|
118
|
+
const hint = timeoutHint(outcome.session.status);
|
|
119
|
+
step(io, false, `provider ${providerRef}: timed out after ${timeoutSeconds}s (${hint})`);
|
|
120
|
+
return {
|
|
121
|
+
provider: providerRef,
|
|
122
|
+
pass: false,
|
|
123
|
+
reason: "timeout",
|
|
124
|
+
sessionId: outcome.sessionId,
|
|
125
|
+
message: hint,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Terminal: already resolved on its own, nothing left for teardown to cancel.
|
|
130
|
+
const { session, sessionId } = outcome;
|
|
131
|
+
if (session.status !== "completed" || session.exitCode !== 0) {
|
|
132
|
+
const detail = session.errorMessage ?? `exitCode=${session.exitCode ?? "n/a"}`;
|
|
133
|
+
step(io, false, `provider ${providerRef}: session ${session.status} (${detail})`);
|
|
134
|
+
return {
|
|
135
|
+
provider: providerRef,
|
|
136
|
+
pass: false,
|
|
137
|
+
reason: "session_failed",
|
|
138
|
+
sessionId,
|
|
139
|
+
status: session.status,
|
|
140
|
+
exitCode: session.exitCode,
|
|
141
|
+
message: detail,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let stdout;
|
|
146
|
+
try {
|
|
147
|
+
stdout = await fetchStdout(client, sessionId);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
step(io, false, `provider ${providerRef}: fetching logs failed: ${error.message}`);
|
|
150
|
+
return {
|
|
151
|
+
provider: providerRef,
|
|
152
|
+
pass: false,
|
|
153
|
+
reason: "logs_failed",
|
|
154
|
+
sessionId,
|
|
155
|
+
message: error.message,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!stdout.includes(marker)) {
|
|
159
|
+
const message = "completed but marker was not found in stdout";
|
|
160
|
+
step(io, false, `provider ${providerRef}: ${message}`);
|
|
161
|
+
return { provider: providerRef, pass: false, reason: "marker_missing", sessionId, message };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
step(io, true, `provider ${providerRef}: PASS`);
|
|
165
|
+
return {
|
|
166
|
+
provider: providerRef,
|
|
167
|
+
pass: true,
|
|
168
|
+
sessionId,
|
|
169
|
+
status: session.status,
|
|
170
|
+
exitCode: session.exitCode,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a throwaway repository for `host smoke` to attach, run one prompt through, and delete
|
|
3
|
+
* again in its teardown. The daemon dispatches sessions against the host-local path this same
|
|
4
|
+
* run attaches (see `smokeInventoryEntry` below) — it never fetches or dials a repository's
|
|
5
|
+
* `url` — so a syntactically valid but inert HTTPS placeholder under the `example.test`
|
|
6
|
+
* reserved TLD (RFC 2606) is fine here, exactly like `e2e/real-cli/real-cli-helpers.ts` and
|
|
7
|
+
* `e2e/control/orchestration.spec.ts` already use for the same reason. `randomHex(bytes)` is
|
|
8
|
+
* injected (defaults to `node:crypto`'s `randomBytes` in `host-smoke.js`) so tests can assert
|
|
9
|
+
* on a deterministic generated name.
|
|
10
|
+
*/
|
|
11
|
+
export async function createSmokeRepository(client, randomHex) {
|
|
12
|
+
const name = `smoke-${randomHex(6)}`;
|
|
13
|
+
return client.request("/repositories", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: JSON.stringify({
|
|
16
|
+
name,
|
|
17
|
+
url: `https://example.test/${name}.git`,
|
|
18
|
+
defaultBranch: "main",
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The inventory entry `host smoke` attaches via `attachRepository` — one worktree, named after
|
|
25
|
+
* the throwaway repository (`smoke-<hex>`), under `<repoPath>/.worktrees/<that name>`. Worktree
|
|
26
|
+
* names are one namespace across every host (`services/api/src/control-plane-worktree-names.ts`),
|
|
27
|
+
* so a fixed name would make two concurrent smokes on different hosts, or a leftover smoke
|
|
28
|
+
* worktree anywhere in the fleet, reject this attach. `repoPath` is a path on the HOST, which may be a
|
|
29
|
+
* different machine from wherever this CLI runs, so this never joins it with `node:path` (whose
|
|
30
|
+
* separator would be wrong for a remote host) or checks it exists — see `host-smoke.js`'s usage
|
|
31
|
+
* text and the README for the preconditions this command cannot verify itself: `repoPath` must
|
|
32
|
+
* already be a git repository with a clean `main` checkout, and `.worktrees/` must be gitignored
|
|
33
|
+
* there so the daemon's worktree checkout never collides with tracked files.
|
|
34
|
+
*/
|
|
35
|
+
export function smokeInventoryEntry(repository, repoPath) {
|
|
36
|
+
return {
|
|
37
|
+
id: repository.id,
|
|
38
|
+
path: repoPath,
|
|
39
|
+
defaultBranch: repository.defaultBranch ?? "main",
|
|
40
|
+
worktrees: [
|
|
41
|
+
{
|
|
42
|
+
id: repository.name,
|
|
43
|
+
name: repository.name,
|
|
44
|
+
path: `${repoPath}/.worktrees/${repository.name}`,
|
|
45
|
+
labels: [],
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|