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.
@@ -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
+ }
@@ -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 rm <hostId> <repositoryId> [--dry-run] [--json]`;
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
+ }
@@ -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
  }