auto-harness-client 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +180 -0
- package/package.json +1 -1
- package/src/cli/args.js +17 -1
- package/src/cli/commands/attach-repository.js +117 -0
- package/src/cli/commands/detach-repository.js +109 -0
- package/src/cli/commands/host-repo-add.js +81 -0
- package/src/cli/commands/host-repo-rm.js +9 -86
- package/src/cli/commands/host-repo.js +7 -2
- package/src/cli/commands/host-smoke-format.js +30 -0
- package/src/cli/commands/host-smoke-poll.js +77 -0
- package/src/cli/commands/host-smoke-provider.js +172 -0
- package/src/cli/commands/host-smoke-repository.js +49 -0
- package/src/cli/commands/host-smoke-session-attempt.js +104 -0
- package/src/cli/commands/host-smoke-teardown.js +164 -0
- package/src/cli/commands/host-smoke.js +151 -0
- package/src/cli/commands/host.js +7 -1
- package/src/cli/commands/parse-worktree-flag.js +30 -0
- package/src/cli/commands/repo-add.js +38 -0
- package/src/cli/commands/repo.js +3 -0
- package/src/cli/commands/session-cancel.js +28 -0
- package/src/cli/commands/session-create.js +117 -0
- package/src/cli/commands/session-get.js +26 -0
- package/src/cli/commands/session-logs.js +65 -0
- package/src/cli/commands/session-target.js +29 -0
- package/src/cli/commands/session.js +22 -0
- package/src/cli/main.js +2 -0
- package/src/cli/session-format.js +15 -0
- package/src/cli/usage.js +17 -0
- package/src/cli/wait-for-session.js +46 -0
|
@@ -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
|
+
}
|
package/src/cli/commands/host.js
CHANGED
|
@@ -4,6 +4,7 @@ import { runHostInventory } from "./host-inventory.js";
|
|
|
4
4
|
import { runHostList } from "./host-list.js";
|
|
5
5
|
import { runHostRepo } from "./host-repo.js";
|
|
6
6
|
import { runHostResume } from "./host-resume.js";
|
|
7
|
+
import { runHostSmoke } from "./host-smoke.js";
|
|
7
8
|
|
|
8
9
|
const USAGE = `usage: auto-harness host <subcommand> ...
|
|
9
10
|
auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]
|
|
@@ -11,7 +12,11 @@ const USAGE = `usage: auto-harness host <subcommand> ...
|
|
|
11
12
|
auto-harness host resume <hostId> [--json]
|
|
12
13
|
auto-harness host inventory get <hostId> [--json]
|
|
13
14
|
auto-harness host inventory set <hostId> --file <path|->
|
|
14
|
-
auto-harness host repo
|
|
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]`;
|
|
15
20
|
|
|
16
21
|
/** Dispatches `host <subcommand>` to its own module — mirrors `main.js`'s own dispatch. */
|
|
17
22
|
export async function runHost(argv, io) {
|
|
@@ -21,5 +26,6 @@ export async function runHost(argv, io) {
|
|
|
21
26
|
if (subcommand === "resume") return runHostResume(rest, io);
|
|
22
27
|
if (subcommand === "inventory") return runHostInventory(rest, io);
|
|
23
28
|
if (subcommand === "repo") return runHostRepo(rest, io);
|
|
29
|
+
if (subcommand === "smoke") return runHostSmoke(rest, io);
|
|
24
30
|
throw new CliUsageError(USAGE);
|
|
25
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
|
+
}
|
package/src/cli/commands/repo.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runRepoAdd } from "./repo-add.js";
|
|
2
3
|
import { runRepoList } from "./repo-list.js";
|
|
3
4
|
import { runRepoRm } from "./repo-rm.js";
|
|
4
5
|
|
|
5
6
|
const USAGE = `usage: auto-harness repo <subcommand> ...
|
|
7
|
+
auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]
|
|
6
8
|
auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
|
|
7
9
|
auto-harness repo rm <repositoryId> [--json]`;
|
|
8
10
|
|
|
9
11
|
/** Dispatches `repo <subcommand>` to its own module — mirrors `host.js`'s own dispatch. */
|
|
10
12
|
export async function runRepo(argv, io) {
|
|
11
13
|
const [subcommand, ...rest] = argv;
|
|
14
|
+
if (subcommand === "add") return runRepoAdd(rest, io);
|
|
12
15
|
if (subcommand === "list") return runRepoList(rest, io);
|
|
13
16
|
if (subcommand === "rm") return runRepoRm(rest, io);
|
|
14
17
|
throw new CliUsageError(USAGE);
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness session cancel <sessionId> [--json]";
|
|
8
|
+
|
|
9
|
+
/** `POST /sessions/<id>/cancel`, via the library's `cancelSession()` (which encodes the id
|
|
10
|
+
* itself). A 404 (unknown id) or 409 (already terminal) surfaces through the normal
|
|
11
|
+
* `reportError` path in `main.js`, same as every other command. */
|
|
12
|
+
export async function runSessionCancel(argv, io) {
|
|
13
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
14
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
15
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
16
|
+
});
|
|
17
|
+
const [sessionId] = positionals;
|
|
18
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
19
|
+
// Validates before any request; the encoded value itself is unused since
|
|
20
|
+
// client.cancelSession() encodes the raw id again on its own.
|
|
21
|
+
pathSegment(sessionId, "sessionId");
|
|
22
|
+
const client = await createClient(flags, io);
|
|
23
|
+
const session = await client.cancelSession(sessionId);
|
|
24
|
+
io.stdout.write(
|
|
25
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
26
|
+
);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
5
|
+
import { waitForSession } from "../wait-for-session.js";
|
|
6
|
+
import { resolveSessionTarget } from "./session-target.js";
|
|
7
|
+
|
|
8
|
+
const USAGE =
|
|
9
|
+
"usage: auto-harness session create --repo <repositoryId> " +
|
|
10
|
+
"(--provider <id|name> | --command <id|name>) --prompt <text> [--timeout <seconds>] " +
|
|
11
|
+
"[--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]";
|
|
12
|
+
|
|
13
|
+
const VALUE_FLAGS = [
|
|
14
|
+
"--repo",
|
|
15
|
+
"--provider",
|
|
16
|
+
"--command",
|
|
17
|
+
"--prompt",
|
|
18
|
+
"--timeout",
|
|
19
|
+
"--ref",
|
|
20
|
+
"--concurrency-id",
|
|
21
|
+
"--wait-timeout",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// Mirrors the create-session form's own default in
|
|
25
|
+
// services/web/src/components/session-timeout-field.tsx (`SessionTimeoutField`,
|
|
26
|
+
// `initialSeconds = 600`). The server requires `timeout`; there is no server-side default
|
|
27
|
+
// (`sessionTimeoutError` in modules/shared/src/validation.ts rejects `undefined`).
|
|
28
|
+
const DEFAULT_TIMEOUT_SECONDS = 600;
|
|
29
|
+
|
|
30
|
+
// Arbitrary, small: keeps `--wait` responsive without hammering the API.
|
|
31
|
+
const WAIT_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
|
|
33
|
+
function parsePositiveNumber(value, label) {
|
|
34
|
+
const parsed = Number(value);
|
|
35
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
36
|
+
throw new CliUsageError(`${label} must be a positive number`);
|
|
37
|
+
}
|
|
38
|
+
return parsed;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function validateFlags(flags) {
|
|
42
|
+
for (const name of VALUE_FLAGS) {
|
|
43
|
+
if (flags[name] !== undefined && flags[name].trim() === "") {
|
|
44
|
+
throw new CliUsageError(`${name} was given an empty value`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!flags["--repo"] || flags["--prompt"] === undefined) throw new CliUsageError(USAGE);
|
|
48
|
+
const hasProvider = flags["--provider"] !== undefined;
|
|
49
|
+
const hasCommand = flags["--command"] !== undefined;
|
|
50
|
+
if (hasProvider === hasCommand) {
|
|
51
|
+
throw new CliUsageError("exactly one of --provider or --command is required");
|
|
52
|
+
}
|
|
53
|
+
if (flags["--wait-timeout"] !== undefined && !flags["--wait"]) {
|
|
54
|
+
throw new CliUsageError("--wait-timeout requires --wait");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `POST /sessions`. `--provider`/`--command` accept either an id or a name (see
|
|
60
|
+
* `session-target.js`); `--timeout` defaults to `DEFAULT_TIMEOUT_SECONDS` since the server
|
|
61
|
+
* requires it but has no default of its own. With `--wait`, polls via `waitForSession()` —
|
|
62
|
+
* status changes go to stderr, the final session record to stdout — and exits 0 only when the
|
|
63
|
+
* session `completed` with `exitCode === 0`; any other terminal status, or the wait timing out,
|
|
64
|
+
* exits 1 (a timed-out wait never cancels the session).
|
|
65
|
+
*/
|
|
66
|
+
export async function runSessionCreate(argv, io) {
|
|
67
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
68
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, ...VALUE_FLAGS],
|
|
69
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--wait", "--json"],
|
|
70
|
+
});
|
|
71
|
+
if (positionals.length > 0) throw new CliUsageError(USAGE);
|
|
72
|
+
validateFlags(flags);
|
|
73
|
+
const timeout =
|
|
74
|
+
flags["--timeout"] !== undefined
|
|
75
|
+
? parsePositiveNumber(flags["--timeout"], "--timeout")
|
|
76
|
+
: DEFAULT_TIMEOUT_SECONDS;
|
|
77
|
+
const waitTimeoutSeconds = flags["--wait"]
|
|
78
|
+
? flags["--wait-timeout"] !== undefined
|
|
79
|
+
? parsePositiveNumber(flags["--wait-timeout"], "--wait-timeout")
|
|
80
|
+
: timeout
|
|
81
|
+
: undefined;
|
|
82
|
+
|
|
83
|
+
const client = await createClient(flags, io);
|
|
84
|
+
const target = await resolveSessionTarget(client, flags);
|
|
85
|
+
const created = await client.createSession({
|
|
86
|
+
repositoryId: flags["--repo"],
|
|
87
|
+
prompt: flags["--prompt"],
|
|
88
|
+
target,
|
|
89
|
+
timeout,
|
|
90
|
+
...(flags["--ref"] !== undefined ? { ref: flags["--ref"] } : {}),
|
|
91
|
+
...(flags["--concurrency-id"] !== undefined
|
|
92
|
+
? { concurrencyId: flags["--concurrency-id"] }
|
|
93
|
+
: {}),
|
|
94
|
+
});
|
|
95
|
+
if (!flags["--wait"]) return writeResult(io, flags, created, 0);
|
|
96
|
+
|
|
97
|
+
const { timedOut, session } = await waitForSession(client, created.id, {
|
|
98
|
+
timeoutMs: waitTimeoutSeconds * 1000,
|
|
99
|
+
intervalMs: WAIT_POLL_INTERVAL_MS,
|
|
100
|
+
onStatus: (status) => io.stderr.write(`session ${created.id}: ${status}\n`),
|
|
101
|
+
});
|
|
102
|
+
if (timedOut) {
|
|
103
|
+
io.stderr.write(
|
|
104
|
+
`session ${created.id} is still running after ${waitTimeoutSeconds}s; not cancelling it\n`,
|
|
105
|
+
);
|
|
106
|
+
return writeResult(io, flags, session, 1);
|
|
107
|
+
}
|
|
108
|
+
const succeeded = session.status === "completed" && session.exitCode === 0;
|
|
109
|
+
return writeResult(io, flags, session, succeeded ? 0 : 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeResult(io, flags, session, exitCode) {
|
|
113
|
+
io.stdout.write(
|
|
114
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
115
|
+
);
|
|
116
|
+
return exitCode;
|
|
117
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 { formatSessionLine } from "../session-format.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = "usage: auto-harness session get <sessionId> [--json]";
|
|
8
|
+
|
|
9
|
+
/** `GET /sessions/<id>`, via the library's `getSession()` (which encodes the id itself). */
|
|
10
|
+
export async function runSessionGet(argv, io) {
|
|
11
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
12
|
+
valueFlags: GLOBAL_VALUE_FLAGS,
|
|
13
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
14
|
+
});
|
|
15
|
+
const [sessionId] = positionals;
|
|
16
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
17
|
+
// Validates before any request (and before an admin-mode login); the encoded value itself is
|
|
18
|
+
// unused since client.getSession() encodes the raw id again on its own.
|
|
19
|
+
pathSegment(sessionId, "sessionId");
|
|
20
|
+
const client = await createClient(flags, io);
|
|
21
|
+
const session = await client.getSession(sessionId);
|
|
22
|
+
io.stdout.write(
|
|
23
|
+
flags["--json"] ? `${JSON.stringify(session, null, 2)}\n` : formatSessionLine(session),
|
|
24
|
+
);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
|
|
6
|
+
const USAGE = "usage: auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]";
|
|
7
|
+
|
|
8
|
+
// Mirrors DEFAULT_LOG_QUERY_LIMIT in services/api/src/log-query.ts — used only to guess whether
|
|
9
|
+
// a page came back full when --limit was not given (see the pagination note below).
|
|
10
|
+
const DEFAULT_LOG_LIMIT = 1000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `GET /sessions/<id>/logs`. Unlike `repo list`/`host list`, this endpoint returns `{ items }`
|
|
14
|
+
* with no `nextCursor` — its only continuation knob is `since` (services/api/src/log-query.ts),
|
|
15
|
+
* a whole ISO-8601 timestamp, exclusive. This CLI exposes that as `--cursor` for a pagination
|
|
16
|
+
* vocabulary consistent with the other list commands, mapped straight to `since`. Passing the
|
|
17
|
+
* last printed item's own `timestamp` back as the next `--cursor` therefore excludes *every*
|
|
18
|
+
* record sharing that exact timestamp, not only the one already shown — coarser than a true row
|
|
19
|
+
* cursor, but that is the bounded contract `parseLogQuery` actually offers (the true `after`
|
|
20
|
+
* cursor is internal-only, used by viewer reconnects, and never exposed over REST). One page is
|
|
21
|
+
* printed; never looped, per this repo's list/history invariant.
|
|
22
|
+
*/
|
|
23
|
+
export async function runSessionLogs(argv, io) {
|
|
24
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
25
|
+
valueFlags: [...GLOBAL_VALUE_FLAGS, "--limit", "--cursor"],
|
|
26
|
+
booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
|
|
27
|
+
});
|
|
28
|
+
const [sessionId] = positionals;
|
|
29
|
+
if (!sessionId || positionals.length > 1) throw new CliUsageError(USAGE);
|
|
30
|
+
for (const name of ["--limit", "--cursor"]) {
|
|
31
|
+
if (flags[name] !== undefined && flags[name].trim() === "") {
|
|
32
|
+
throw new CliUsageError(`${name} was given an empty value`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const limit = flags["--limit"] !== undefined ? Number(flags["--limit"]) : DEFAULT_LOG_LIMIT;
|
|
36
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
37
|
+
throw new CliUsageError("--limit must be a positive integer");
|
|
38
|
+
}
|
|
39
|
+
const sessionSegment = pathSegment(sessionId, "sessionId");
|
|
40
|
+
const client = await createClient(flags, io);
|
|
41
|
+
const query = new URLSearchParams();
|
|
42
|
+
if (flags["--limit"] !== undefined) query.set("limit", flags["--limit"]);
|
|
43
|
+
if (flags["--cursor"] !== undefined) query.set("since", flags["--cursor"]);
|
|
44
|
+
const suffix = query.toString();
|
|
45
|
+
const page = await client.request(
|
|
46
|
+
`/sessions/${sessionSegment}/logs${suffix ? `?${suffix}` : ""}`,
|
|
47
|
+
);
|
|
48
|
+
if (flags["--json"]) {
|
|
49
|
+
io.stdout.write(`${JSON.stringify(page, null, 2)}\n`);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
const items = page.items ?? [];
|
|
53
|
+
io.stdout.write(formatLogLines(items));
|
|
54
|
+
if (items.length === limit) {
|
|
55
|
+
io.stdout.write(
|
|
56
|
+
`more logs may be available; pass --cursor ${items.at(-1).timestamp} to continue\n`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function formatLogLines(items) {
|
|
63
|
+
if (items.length === 0) return "(no logs)\n";
|
|
64
|
+
return `${items.map((item) => `${item.timestamp} [${item.stream}] ${item.content}`).join("\n")}\n`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves `--provider <id|name>` or `--command <id|name>` to a `TargetRef` for `createSession()`.
|
|
3
|
+
* Exactly one of `flags["--provider"]`/`flags["--command"]` must already be set — callers
|
|
4
|
+
* validate that mutual-exclusivity/required check before calling this.
|
|
5
|
+
*
|
|
6
|
+
* Lists the relevant catalog once (`client.listProviders()`/`listCommands()`) and checks for an
|
|
7
|
+
* exact `id` match locally. A hit returns an already-id-shaped ref, which `resolveTargetSpecs`
|
|
8
|
+
* (inside `client.createSession()`) passes straight through with no further request. A miss
|
|
9
|
+
* returns a `providerName`/`commandName` ref instead — deliberately *not* resolved here — so
|
|
10
|
+
* `createSession()`'s own `resolveCreateSessionTargets()` resolves it, reusing its exact
|
|
11
|
+
* not-found/ambiguous-name errors (`UNKNOWN_PROVIDER_NAME`, `AMBIGUOUS_PROVIDER_NAME`, ...)
|
|
12
|
+
* rather than reimplementing that matching. One consequence: a mistyped id surfaces as
|
|
13
|
+
* `no provider named "<value>"`, not a distinct "unknown id" error.
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveSessionTarget(client, flags) {
|
|
16
|
+
if (flags["--provider"] !== undefined) {
|
|
17
|
+
return resolveOne(
|
|
18
|
+
await client.listProviders(),
|
|
19
|
+
flags["--provider"],
|
|
20
|
+
"providerId",
|
|
21
|
+
"providerName",
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return resolveOne(await client.listCommands(), flags["--command"], "commandId", "commandName");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveOne(items, value, idKey, nameKey) {
|
|
28
|
+
return items.some((item) => item.id === value) ? { [idKey]: value } : { [nameKey]: value };
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { CliUsageError } from "../cli-errors.js";
|
|
2
|
+
import { runSessionCancel } from "./session-cancel.js";
|
|
3
|
+
import { runSessionCreate } from "./session-create.js";
|
|
4
|
+
import { runSessionGet } from "./session-get.js";
|
|
5
|
+
import { runSessionLogs } from "./session-logs.js";
|
|
6
|
+
|
|
7
|
+
const USAGE = `usage: auto-harness session <subcommand> ...
|
|
8
|
+
auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text>
|
|
9
|
+
[--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]
|
|
10
|
+
auto-harness session get <sessionId> [--json]
|
|
11
|
+
auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]
|
|
12
|
+
auto-harness session cancel <sessionId> [--json]`;
|
|
13
|
+
|
|
14
|
+
/** Dispatches `session <subcommand>` to its own module — mirrors `host.js`'s own dispatch. */
|
|
15
|
+
export async function runSession(argv, io) {
|
|
16
|
+
const [subcommand, ...rest] = argv;
|
|
17
|
+
if (subcommand === "create") return runSessionCreate(rest, io);
|
|
18
|
+
if (subcommand === "get") return runSessionGet(rest, io);
|
|
19
|
+
if (subcommand === "logs") return runSessionLogs(rest, io);
|
|
20
|
+
if (subcommand === "cancel") return runSessionCancel(rest, io);
|
|
21
|
+
throw new CliUsageError(USAGE);
|
|
22
|
+
}
|
package/src/cli/main.js
CHANGED
|
@@ -3,6 +3,7 @@ import { runDoctor } from "./commands/doctor.js";
|
|
|
3
3
|
import { runHost } from "./commands/host.js";
|
|
4
4
|
import { runRepo } from "./commands/repo.js";
|
|
5
5
|
import { runServiceAccount } from "./commands/service-account.js";
|
|
6
|
+
import { runSession } from "./commands/session.js";
|
|
6
7
|
import { runWhoami } from "./commands/whoami.js";
|
|
7
8
|
import { GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "./config.js";
|
|
8
9
|
import { reportError } from "./report-error.js";
|
|
@@ -61,6 +62,7 @@ export async function main(argv, io) {
|
|
|
61
62
|
if (command === "host") return await runHost(rest, io);
|
|
62
63
|
if (command === "repo") return await runRepo(rest, io);
|
|
63
64
|
if (command === "service-account") return await runServiceAccount(rest, io);
|
|
65
|
+
if (command === "session") return await runSession(rest, io);
|
|
64
66
|
io.stderr.write(usage());
|
|
65
67
|
return 2;
|
|
66
68
|
} catch (error) {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line human summary shared by `session create --wait`, `session get`, and `session cancel`:
|
|
3
|
+
* id, status, and (only when present) exit code, error code, and error message. Session records
|
|
4
|
+
* use `errorCode`/`errorMessage` (never `error`) and have no top-level `summary` — see
|
|
5
|
+
* `SessionRecord` in services/api/src/db/types.ts.
|
|
6
|
+
*/
|
|
7
|
+
export function formatSessionLine(session) {
|
|
8
|
+
const parts = [session.id, session.status];
|
|
9
|
+
if (session.exitCode !== undefined && session.exitCode !== null) {
|
|
10
|
+
parts.push(`exitCode=${session.exitCode}`);
|
|
11
|
+
}
|
|
12
|
+
if (session.errorCode !== undefined) parts.push(`errorCode=${session.errorCode}`);
|
|
13
|
+
if (session.errorMessage !== undefined) parts.push(`errorMessage=${session.errorMessage}`);
|
|
14
|
+
return `${parts.join(" ")}\n`;
|
|
15
|
+
}
|
package/src/cli/usage.js
CHANGED
|
@@ -15,13 +15,23 @@ Usage:
|
|
|
15
15
|
auto-harness host resume <hostId> [--json]
|
|
16
16
|
auto-harness host inventory get <hostId> [--json]
|
|
17
17
|
auto-harness host inventory set <hostId> --file <path|->
|
|
18
|
+
auto-harness host repo add <hostId> <repositoryId> --path <path> [--worktree <id>=<path>]...
|
|
19
|
+
[--default-branch <branch>] [--dry-run] [--json]
|
|
18
20
|
auto-harness host repo rm <hostId> <repositoryId> [--dry-run] [--json]
|
|
21
|
+
auto-harness host smoke <hostId> --repo-path <path> --provider <id|name>
|
|
22
|
+
[--provider <id|name>]... [--timeout <seconds>] [--json]
|
|
23
|
+
auto-harness repo add --name <name> --url <url> [--default-branch <branch>] [--json]
|
|
19
24
|
auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
|
|
20
25
|
auto-harness repo rm <repositoryId> [--json]
|
|
21
26
|
auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]
|
|
22
27
|
auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>]
|
|
23
28
|
[--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]
|
|
24
29
|
auto-harness service-account rm <id> [--json]
|
|
30
|
+
auto-harness session create --repo <repositoryId> (--provider <id|name> | --command <id|name>) --prompt <text>
|
|
31
|
+
[--timeout <seconds>] [--ref <ref>] [--concurrency-id <id>] [--wait [--wait-timeout <seconds>]] [--json]
|
|
32
|
+
auto-harness session get <sessionId> [--json]
|
|
33
|
+
auto-harness session logs <sessionId> [--limit N] [--cursor C] [--json]
|
|
34
|
+
auto-harness session cancel <sessionId> [--json]
|
|
25
35
|
auto-harness help | --help | -h
|
|
26
36
|
|
|
27
37
|
Configuration:
|
|
@@ -53,7 +63,10 @@ Examples:
|
|
|
53
63
|
auto-harness host list --online
|
|
54
64
|
auto-harness host drain host-1
|
|
55
65
|
auto-harness host inventory get host-1 --json > inventory.json
|
|
66
|
+
auto-harness host repo add host-1 repo-1 --path /repos/repo-1
|
|
56
67
|
auto-harness host repo rm host-1 repo-1 --dry-run
|
|
68
|
+
auto-harness host smoke host-1 --repo-path /repos/repo-1 --provider claude
|
|
69
|
+
auto-harness repo add --name org/repo --url https://github.com/org/repo
|
|
57
70
|
auto-harness repo list --all
|
|
58
71
|
auto-harness repo rm repo-1
|
|
59
72
|
auto-harness service-account list
|
|
@@ -62,5 +75,9 @@ Examples:
|
|
|
62
75
|
aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \\
|
|
63
76
|
--query Parameter.Value --output text \\
|
|
64
77
|
| auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
|
|
78
|
+
auto-harness session create --repo repo-1 --command claude-print --prompt "Review the diff" --wait
|
|
79
|
+
auto-harness session get session-1
|
|
80
|
+
auto-harness session logs session-1 --limit 200
|
|
81
|
+
auto-harness session cancel session-1
|
|
65
82
|
`;
|
|
66
83
|
}
|