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.
Files changed (59) hide show
  1. package/README.md +467 -0
  2. package/package.json +5 -1
  3. package/src/cli/admin-login.js +71 -0
  4. package/src/cli/allowlist.js +34 -0
  5. package/src/cli/args.js +57 -0
  6. package/src/cli/cli-errors.js +16 -0
  7. package/src/cli/commands/api.js +91 -0
  8. package/src/cli/commands/attach-repository.js +117 -0
  9. package/src/cli/commands/dependency-conflict.js +15 -0
  10. package/src/cli/commands/detach-repository.js +109 -0
  11. package/src/cli/commands/doctor.js +164 -0
  12. package/src/cli/commands/host-drain.js +22 -0
  13. package/src/cli/commands/host-inventory-get.js +40 -0
  14. package/src/cli/commands/host-inventory-set.js +66 -0
  15. package/src/cli/commands/host-inventory.js +15 -0
  16. package/src/cli/commands/host-list.js +87 -0
  17. package/src/cli/commands/host-post-action.js +38 -0
  18. package/src/cli/commands/host-repo-add.js +81 -0
  19. package/src/cli/commands/host-repo-rm.js +51 -0
  20. package/src/cli/commands/host-repo.js +16 -0
  21. package/src/cli/commands/host-resume.js +17 -0
  22. package/src/cli/commands/host-smoke-format.js +30 -0
  23. package/src/cli/commands/host-smoke-poll.js +77 -0
  24. package/src/cli/commands/host-smoke-provider.js +172 -0
  25. package/src/cli/commands/host-smoke-repository.js +49 -0
  26. package/src/cli/commands/host-smoke-session-attempt.js +104 -0
  27. package/src/cli/commands/host-smoke-teardown.js +164 -0
  28. package/src/cli/commands/host-smoke.js +151 -0
  29. package/src/cli/commands/host.js +31 -0
  30. package/src/cli/commands/parse-worktree-flag.js +30 -0
  31. package/src/cli/commands/repo-add.js +38 -0
  32. package/src/cli/commands/repo-list.js +84 -0
  33. package/src/cli/commands/repo-rm.js +83 -0
  34. package/src/cli/commands/repo.js +18 -0
  35. package/src/cli/commands/service-account-create.js +114 -0
  36. package/src/cli/commands/service-account-list.js +84 -0
  37. package/src/cli/commands/service-account-rm.js +48 -0
  38. package/src/cli/commands/service-account.js +19 -0
  39. package/src/cli/commands/session-cancel.js +28 -0
  40. package/src/cli/commands/session-create.js +117 -0
  41. package/src/cli/commands/session-get.js +26 -0
  42. package/src/cli/commands/session-logs.js +65 -0
  43. package/src/cli/commands/session-target.js +29 -0
  44. package/src/cli/commands/session.js +22 -0
  45. package/src/cli/commands/whoami.js +22 -0
  46. package/src/cli/config.js +96 -0
  47. package/src/cli/index.js +21 -0
  48. package/src/cli/main.js +71 -0
  49. package/src/cli/path-segment.js +20 -0
  50. package/src/cli/read-stdin.js +9 -0
  51. package/src/cli/report-error.js +34 -0
  52. package/src/cli/service-account-format.js +24 -0
  53. package/src/cli/session-format.js +15 -0
  54. package/src/cli/usage.js +83 -0
  55. package/src/cli/wait-for-session.js +46 -0
  56. package/src/errors.js +1 -0
  57. package/src/index.d.ts +64 -4
  58. package/src/index.js +76 -30
  59. package/src/resolve-target.js +29 -13
@@ -0,0 +1,22 @@
1
+ import { allowlistPrincipal, formatWhoami } from "../allowlist.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
+
6
+ /** `GET /auth/me`, printing only the allowlisted principal fields (see allowlist.js for why the
7
+ * response shape is not trusted). */
8
+ export async function runWhoami(argv, io) {
9
+ const { flags, positionals } = parseFlags(argv, {
10
+ valueFlags: GLOBAL_VALUE_FLAGS,
11
+ booleanFlags: [...GLOBAL_BOOLEAN_FLAGS, "--json"],
12
+ });
13
+ if (positionals.length > 0) {
14
+ throw new CliUsageError(`whoami takes no arguments; received: ${positionals.join(" ")}`);
15
+ }
16
+ const client = await createClient(flags, io);
17
+ const principal = allowlistPrincipal(await client.request("/auth/me"));
18
+ io.stdout.write(
19
+ flags["--json"] ? `${JSON.stringify(principal, null, 2)}\n` : formatWhoami(principal),
20
+ );
21
+ return 0;
22
+ }
@@ -0,0 +1,96 @@
1
+ import { AutoHarnessClient } from "../index.js";
2
+ import { checkAdminLoginUsage, loginAsAdmin } from "./admin-login.js";
3
+ import { CliConfigError } from "./cli-errors.js";
4
+
5
+ export const GLOBAL_VALUE_FLAGS = ["--api-url", "--api-key-file", "--admin-username"];
6
+ export const GLOBAL_BOOLEAN_FLAGS = ["--allow-insecure-http", "--admin-password-stdin"];
7
+
8
+ /** A flag that is present wins, even with an empty value — `--api-url=` or
9
+ * `--api-key-file "$UNSET"` is an explicit choice that went wrong, not an absent flag. Falling
10
+ * back to the environment would silently use a different URL, or authenticate as a different
11
+ * principal, than the one the command line asked for. So an empty value is a config error. */
12
+ function explicitFlag(flags, name) {
13
+ if (!Object.hasOwn(flags, name)) return undefined;
14
+ if (flags[name].trim() === "") throw new CliConfigError(`${name} was given an empty value`);
15
+ return flags[name];
16
+ }
17
+
18
+ /** `--api-url`, else `HARNESS_API_URL`, else `HARNESS_API_HTTP` (the host daemon's own alias —
19
+ * operators already have one of these two set). Missing entirely is a config error naming both. */
20
+ export function resolveApiUrl(flags, env) {
21
+ const url = explicitFlag(flags, "--api-url") ?? (env.HARNESS_API_URL || env.HARNESS_API_HTTP);
22
+ if (!url) {
23
+ throw new CliConfigError(
24
+ "no API base URL configured: set --api-url, or the HARNESS_API_URL environment " +
25
+ "variable (alias: HARNESS_API_HTTP)",
26
+ );
27
+ }
28
+ return url;
29
+ }
30
+
31
+ /**
32
+ * Precedence: an explicit `--api-key-file` flag wins outright — it is the most specific,
33
+ * most intentional signal an operator can give on any one invocation. Failing that, the direct
34
+ * `HARNESS_API_KEY` value wins over the indirect `HARNESS_API_KEY_FILE` pointer, since a plain
35
+ * env var is one less level of indirection to reason about. Any consistent order satisfies the
36
+ * spec here; this one mirrors "flag beats env" from `resolveApiUrl` above.
37
+ */
38
+ export async function resolveApiKey(flags, env, readFile) {
39
+ const keyFile = explicitFlag(flags, "--api-key-file");
40
+ if (keyFile !== undefined) return readApiKeyFile(keyFile, readFile);
41
+ if (env.HARNESS_API_KEY) return env.HARNESS_API_KEY.trim();
42
+ if (env.HARNESS_API_KEY_FILE) return readApiKeyFile(env.HARNESS_API_KEY_FILE, readFile);
43
+ return undefined;
44
+ }
45
+
46
+ async function readApiKeyFile(path, readFile) {
47
+ let contents;
48
+ try {
49
+ contents = await readFile(path, "utf8");
50
+ } catch (error) {
51
+ throw new CliConfigError(`could not read API key file ${path}: ${error.message}`);
52
+ }
53
+ const trimmed = contents.trim();
54
+ if (!trimmed) throw new CliConfigError(`API key file ${path} is empty`);
55
+ return trimmed;
56
+ }
57
+
58
+ /**
59
+ * `--admin-password-stdin` replaces the whole apiKey identity with an admin username/password
60
+ * login, so its usage checks run here — unconditionally, for every command — before either an
61
+ * apiKey is resolved or (in `createClient`) the login request is made.
62
+ */
63
+ export async function resolveConfig(flags, io) {
64
+ const baseUrl = resolveApiUrl(flags, io.env);
65
+ checkAdminLoginUsage(flags, io.env);
66
+ const allowInsecureHttp = Boolean(flags["--allow-insecure-http"]);
67
+ if (flags["--admin-password-stdin"]) {
68
+ const adminUsername = explicitFlag(flags, "--admin-username") ?? "admin";
69
+ return { baseUrl, allowInsecureHttp, adminMode: true, adminUsername };
70
+ }
71
+ const apiKey = await resolveApiKey(flags, io.env, io.readFile);
72
+ return { baseUrl, apiKey, allowInsecureHttp };
73
+ }
74
+
75
+ /** Builds the real `AutoHarnessClient`; a constructor rejection (e.g. plaintext http with an
76
+ * apiKey set against a non-loopback host) is a configuration problem, not an API failure, so it
77
+ * is re-thrown as `CliConfigError` (exit 2) rather than surfacing as a generic exit-1 error. A
78
+ * failed admin login (a bad password, an unreachable server) is deliberately *not* wrapped this
79
+ * way — it is reported as a plain error (exit 1), matching a rejected API key rather than a
80
+ * usage/config problem. */
81
+ export async function createClient(flags, io) {
82
+ const config = await resolveConfig(flags, io);
83
+ const fetchFn = config.adminMode
84
+ ? (await loginAsAdmin(io, config.baseUrl, config.adminUsername, config.allowInsecureHttp)).fetch
85
+ : io.fetch;
86
+ try {
87
+ return new AutoHarnessClient({
88
+ baseUrl: config.baseUrl,
89
+ apiKey: config.apiKey,
90
+ fetch: fetchFn,
91
+ allowInsecureHttp: config.allowInsecureHttp,
92
+ });
93
+ } catch (error) {
94
+ throw new CliConfigError(error.message);
95
+ }
96
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+
4
+ import { main } from "./main.js";
5
+
6
+ // A thin wrapper: every dependency on the outside world is injected here, so `main()` and
7
+ // everything it calls stay unit-testable without spawning a process or touching the network.
8
+ const io = {
9
+ env: process.env,
10
+ fetch: globalThis.fetch,
11
+ stdout: process.stdout,
12
+ stderr: process.stderr,
13
+ stdin: process.stdin,
14
+ readFile,
15
+ // O_CREAT | O_EXCL ("wx") — never overwrites an existing file. Used only for
16
+ // `service-account create --key-file`, where a plaintext API key is written to disk exactly
17
+ // once and mode 0600 (passed by the caller) keeps it readable only by its owner.
18
+ writeFileExclusive: (path, data, options) => writeFile(path, data, { flag: "wx", ...options }),
19
+ };
20
+
21
+ process.exitCode = await main(process.argv.slice(2), io);
@@ -0,0 +1,71 @@
1
+ import { runApi } from "./commands/api.js";
2
+ import { runDoctor } from "./commands/doctor.js";
3
+ import { runHost } from "./commands/host.js";
4
+ import { runRepo } from "./commands/repo.js";
5
+ import { runServiceAccount } from "./commands/service-account.js";
6
+ import { runSession } from "./commands/session.js";
7
+ import { runWhoami } from "./commands/whoami.js";
8
+ import { GLOBAL_BOOLEAN_FLAGS, GLOBAL_VALUE_FLAGS } from "./config.js";
9
+ import { reportError } from "./report-error.js";
10
+ import { usage } from "./usage.js";
11
+
12
+ const HELP_TOKENS = new Set(["help", "--help", "-h"]);
13
+
14
+ /**
15
+ * A global flag (`--admin-password-stdin`, `--api-url`, ...) is recognized wherever it appears,
16
+ * not only after the command name — `auto-harness --admin-password-stdin service-account create`
17
+ * reads the same as `auto-harness service-account create --admin-password-stdin`. This walks
18
+ * only the *leading* run of `--flag` tokens (an unknown leading flag, e.g. a mistyped one or the
19
+ * rejected `--api-key`, is left in place and falls through to the usage/exit-2 path below,
20
+ * exactly as an unrecognized command would), moves each one after the command name, and hands
21
+ * the rest of `argv` to the matched subcommand's own `parseFlags` untouched.
22
+ */
23
+ function hoistLeadingGlobalFlags(argv) {
24
+ const hoisted = [];
25
+ let index = 0;
26
+ while (index < argv.length && argv[index].startsWith("--")) {
27
+ const arg = argv[index];
28
+ const equals = arg.indexOf("=");
29
+ const name = equals === -1 ? arg : arg.slice(0, equals);
30
+ if (GLOBAL_BOOLEAN_FLAGS.includes(name) && equals === -1) {
31
+ hoisted.push(arg);
32
+ index += 1;
33
+ } else if (GLOBAL_VALUE_FLAGS.includes(name) && equals !== -1) {
34
+ hoisted.push(arg);
35
+ index += 1;
36
+ } else if (GLOBAL_VALUE_FLAGS.includes(name) && index + 1 < argv.length) {
37
+ hoisted.push(arg, argv[index + 1]);
38
+ index += 2;
39
+ } else {
40
+ break;
41
+ }
42
+ }
43
+ return { command: argv[index], rest: [...argv.slice(index + 1), ...hoisted] };
44
+ }
45
+
46
+ /**
47
+ * Runs the CLI end to end and resolves to a process exit code — 0 on success, 1 for an
48
+ * API/HTTP failure or a failed `doctor` check, 2 for a usage or configuration error. Every
49
+ * dependency on the outside world (env, fetch, the standard streams, file reads) is injected
50
+ * through `io` so this never spawns a process or touches the real network in tests.
51
+ */
52
+ export async function main(argv, io) {
53
+ const { command, rest } = hoistLeadingGlobalFlags(argv);
54
+ if (command === undefined || HELP_TOKENS.has(command)) {
55
+ io.stdout.write(usage());
56
+ return 0;
57
+ }
58
+ try {
59
+ if (command === "api") return await runApi(rest, io);
60
+ if (command === "whoami") return await runWhoami(rest, io);
61
+ if (command === "doctor") return await runDoctor(rest, io);
62
+ if (command === "host") return await runHost(rest, io);
63
+ if (command === "repo") return await runRepo(rest, io);
64
+ if (command === "service-account") return await runServiceAccount(rest, io);
65
+ if (command === "session") return await runSession(rest, io);
66
+ io.stderr.write(usage());
67
+ return 2;
68
+ } catch (error) {
69
+ return reportError(error, io);
70
+ }
71
+ }
@@ -0,0 +1,20 @@
1
+ import { CliUsageError } from "./cli-errors.js";
2
+
3
+ /**
4
+ * Encodes one path segment taken from the command line, such as a host or repository id.
5
+ *
6
+ * `encodeURIComponent` leaves `.` untouched, so an id of `.` or `..` survives encoding and the
7
+ * URL parser then resolves it as a dot segment: `repo rm ..` would send
8
+ * `DELETE /api/v1/repositories/..`, which is `DELETE /api/v1/`. No real id is `.` or `..`, so
9
+ * both are rejected. Percent-encoded dots need no check here: encoding turns `%2e` into `%252e`,
10
+ * which the parser does not treat as a dot segment.
11
+ *
12
+ * Call it as soon as the positional is parsed, before `createClient` — in admin mode that makes
13
+ * a network login, and a bad id should not cost one.
14
+ */
15
+ export function pathSegment(value, label) {
16
+ if (value === "." || value === "..") {
17
+ throw new CliUsageError(`${label} must not be "." or ".."`);
18
+ }
19
+ return encodeURIComponent(value);
20
+ }
@@ -0,0 +1,9 @@
1
+ /** Reads an injected `stdin` (any async-iterable of `Buffer`/`string` chunks — the real
2
+ * `process.stdin`, or a `Readable.from([...])` in tests) fully into a UTF-8 string. */
3
+ export async function readStdin(stream) {
4
+ const chunks = [];
5
+ for await (const chunk of stream) {
6
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
7
+ }
8
+ return Buffer.concat(chunks).toString("utf8");
9
+ }
@@ -0,0 +1,34 @@
1
+ import { AutoHarnessError, AutoHarnessRequestTimeoutError } from "../errors.js";
2
+ import { CliConfigError, CliUsageError } from "./cli-errors.js";
3
+
4
+ /** Writes a one-line error (plus, for an `AutoHarnessError`, any `details` fields beyond
5
+ * `code`/`message` — e.g. a refused delete's `dependencies`) to `io.stderr` and returns the
6
+ * process exit code for it: 2 for a usage/config error, 1 for anything else. */
7
+ export function reportError(error, io) {
8
+ if (error instanceof CliUsageError || error instanceof CliConfigError) {
9
+ io.stderr.write(`error: ${error.message}\n`);
10
+ return 2;
11
+ }
12
+ if (error instanceof AutoHarnessRequestTimeoutError) {
13
+ io.stderr.write(`error: ${error.message}\n`);
14
+ return 1;
15
+ }
16
+ if (error instanceof AutoHarnessError) {
17
+ io.stderr.write(`error: ${error.message} (HTTP ${error.status}, ${error.code})\n`);
18
+ const extra = extraDetailFields(error.details);
19
+ if (extra) io.stderr.write(`${JSON.stringify(extra, null, 2)}\n`);
20
+ return 1;
21
+ }
22
+ io.stderr.write(`error: ${error.message}\n`);
23
+ return 1;
24
+ }
25
+
26
+ function extraDetailFields(details) {
27
+ if (!details || typeof details !== "object") return undefined;
28
+ const extra = {};
29
+ for (const [key, value] of Object.entries(details)) {
30
+ if (key === "code" || key === "message") continue;
31
+ extra[key] = value;
32
+ }
33
+ return Object.keys(extra).length > 0 ? extra : undefined;
34
+ }
@@ -0,0 +1,24 @@
1
+ import { allowlistPrincipal } from "./allowlist.js";
2
+
3
+ /**
4
+ * `allowlistPrincipal` plus `createdAt`. `GET /auth/service-accounts` items (and the `account`
5
+ * returned by `POST /auth/service-accounts`) are already server-sanitized (`publicPrincipal`)
6
+ * plus `name`/`createdAt`, but this is printed through the same allowlist as `whoami`/`doctor`
7
+ * as defense in depth — `createdAt` is added back on top since it is not part of a bare
8
+ * principal and so is not in `ALLOWED_PRINCIPAL_FIELDS`.
9
+ */
10
+ export function allowlistAccount(account) {
11
+ const allowed = allowlistPrincipal(account);
12
+ if (account && typeof account === "object" && Object.hasOwn(account, "createdAt")) {
13
+ allowed.createdAt = account.createdAt;
14
+ }
15
+ return allowed;
16
+ }
17
+
18
+ /** One-line human summary: id, name, role, and (when present) boundHostId/createdAt. */
19
+ export function formatAccountLine(account) {
20
+ const parts = [account.id, account.name, account.role];
21
+ if (account.boundHostId) parts.push(account.boundHostId);
22
+ if (account.createdAt) parts.push(account.createdAt);
23
+ return parts.join(" ");
24
+ }
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ export function usage() {
2
+ return `auto-harness - operator CLI for the Auto Harness control plane API
3
+
4
+ A global flag (--api-url, --api-key-file, --allow-insecure-http, --admin-password-stdin,
5
+ --admin-username) is recognized before or after the command name — both
6
+ \`auto-harness --admin-password-stdin whoami\` and \`auto-harness whoami --admin-password-stdin\`
7
+ work the same way.
8
+
9
+ Usage:
10
+ auto-harness api <METHOD> <path> [--body <json> | --body-file <path|->]
11
+ auto-harness whoami [--json]
12
+ auto-harness doctor
13
+ auto-harness host list [--online | --offline] [--limit N] [--cursor C] [--all] [--json]
14
+ auto-harness host drain <hostId> [--json]
15
+ auto-harness host resume <hostId> [--json]
16
+ auto-harness host inventory get <hostId> [--json]
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]
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]
24
+ auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
25
+ auto-harness repo rm <repositoryId> [--json]
26
+ auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]
27
+ auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>]
28
+ [--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]
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]
35
+ auto-harness help | --help | -h
36
+
37
+ Configuration:
38
+ --api-url <url> Control plane base URL (else HARNESS_API_URL, else HARNESS_API_HTTP)
39
+ --api-key-file <path> Read the API key from a file, trimmed (else HARNESS_API_KEY_FILE)
40
+ --allow-insecure-http Allow a plain http:// baseUrl (loopback only; local dev)
41
+
42
+ The API key is never accepted as a command-line flag: it would land in \`ps\` output and shell
43
+ history. Set the HARNESS_API_KEY environment variable, or point --api-key-file /
44
+ HARNESS_API_KEY_FILE at a file holding it.
45
+
46
+ Admin bootstrap (no API key exists yet):
47
+ --admin-password-stdin Log in as an admin (password piped through stdin) instead of
48
+ using an API key; combine with --admin-username (default: admin)
49
+ --admin-username <name> Admin username for --admin-password-stdin (default: admin)
50
+
51
+ --admin-password-stdin reads the password from stdin (one trailing newline stripped), logs in
52
+ once via POST /auth/login, and carries the session cookie on every later request instead of an
53
+ API key — it never touches argv, shell history, or output. It cannot be combined with an API key
54
+ (--api-key-file, HARNESS_API_KEY, or HARNESS_API_KEY_FILE), nor with a command that also reads
55
+ stdin for its own input (\`api --body-file -\`, \`host inventory set --file -\`).
56
+
57
+ Examples:
58
+ auto-harness whoami
59
+ auto-harness api GET /hosts
60
+ auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
61
+ auto-harness api DELETE /repositories/repo-1 --body-file -
62
+ auto-harness doctor
63
+ auto-harness host list --online
64
+ auto-harness host drain host-1
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
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
70
+ auto-harness repo list --all
71
+ auto-harness repo rm repo-1
72
+ auto-harness service-account list
73
+ auto-harness service-account create --name ci --role operator --print-key > /dev/null
74
+ KEY=$(auto-harness service-account create --name ci --role operator --print-key)
75
+ aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \\
76
+ --query Parameter.Value --output text \\
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
82
+ `;
83
+ }
@@ -0,0 +1,46 @@
1
+ // Copied from `TERMINAL_SESSION_STATUSES` in modules/shared/src/constants.ts — this package is
2
+ // dependency-free (no `@auto-harness/shared` import), so the list is duplicated here rather than
3
+ // imported. Keep in sync if the shared list ever changes.
4
+ const TERMINAL_SESSION_STATUSES = ["completed", "failed", "cancelled", "timed_out"];
5
+
6
+ function defaultSleep(ms) {
7
+ return new Promise((resolve) => setTimeout(resolve, ms));
8
+ }
9
+
10
+ /**
11
+ * Polls `client.getSession(id)` until it reports a terminal status (see
12
+ * `TERMINAL_SESSION_STATUSES` above), or until `timeoutMs` elapses while the session is still
13
+ * active. The first fetch always happens, before the budget is even checked, so an
14
+ * already-terminal session resolves correctly even with a tiny `timeoutMs`. `onStatus(status,
15
+ * session)` fires once per *change* in status, including the first observed one — never on every
16
+ * poll — so a caller can stream progress (e.g. to stderr) without duplicate lines.
17
+ *
18
+ * Resolves — never rejects on a timeout — to `{ timedOut: false, session }` once terminal, or
19
+ * `{ timedOut: true, session }` (the last fetched, still-active record) once the budget elapses.
20
+ * This never cancels the session itself; the caller decides what a timeout means. A `getSession`
21
+ * rejection (a real API/network failure) propagates as-is.
22
+ *
23
+ * `sleep`/`now` are injectable so tests never really wait; `intervalMs` is clamped to the time
24
+ * remaining before `timeoutMs` on the final poll.
25
+ */
26
+ export async function waitForSession(
27
+ client,
28
+ id,
29
+ { timeoutMs, intervalMs, sleep = defaultSleep, now = Date.now, onStatus } = {},
30
+ ) {
31
+ const deadline = now() + timeoutMs;
32
+ let lastStatus;
33
+ for (;;) {
34
+ const session = await client.getSession(id);
35
+ if (session.status !== lastStatus) {
36
+ lastStatus = session.status;
37
+ onStatus?.(session.status, session);
38
+ }
39
+ if (TERMINAL_SESSION_STATUSES.includes(session.status)) {
40
+ return { timedOut: false, session };
41
+ }
42
+ const remainingMs = deadline - now();
43
+ if (remainingMs <= 0) return { timedOut: true, session };
44
+ await sleep(Math.min(intervalMs, remainingMs));
45
+ }
46
+ }
package/src/errors.js CHANGED
@@ -7,6 +7,7 @@ export class AutoHarnessError extends Error {
7
7
  this.retryAfter = options.retryAfter;
8
8
  this.operationId = options.operationId;
9
9
  this.statusUrl = options.statusUrl;
10
+ this.details = options.details;
10
11
  }
11
12
  }
12
13
 
package/src/index.d.ts CHANGED
@@ -1,6 +1,17 @@
1
+ /* eslint-disable max-lines -- public client declarations share one compatibility surface. */
1
2
  import type { Command, Provider } from "./catalog-types.js";
2
3
  export type { Command, Provider, ResumeRefCapture, UsageRates } from "./catalog-types.js";
3
4
 
5
+ export type SessionResult = {
6
+ summary: string;
7
+ summarySource: "agent" | "harness";
8
+ summaryTruncated?: true;
9
+ branch?: string;
10
+ filesChanged?: string[];
11
+ filesChangedTruncated?: true;
12
+ pullRequestUrl?: string;
13
+ };
14
+
4
15
  export type TargetRef =
5
16
  | { commandId: string; providerId?: never }
6
17
  | { providerId: string; commandId?: never };
@@ -31,7 +42,7 @@ export type RepositoryRef =
31
42
  export type SessionMetadataValue = string | number | boolean | null;
32
43
 
33
44
  /** Whether a session was created directly or fired by a schedule. */
34
- export type SessionType = "prompt" | "scheduled";
45
+ export type SessionType = "prompt" | "scheduled" | "workspace";
35
46
 
36
47
  /** Origin that requested the session. */
37
48
  export type SessionSource = "api" | "ui" | "webhook" | "schedule";
@@ -39,7 +50,7 @@ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
39
50
  /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
40
51
  export type CreatableSessionSource = "api" | "ui" | "webhook";
41
52
 
42
- export type CreateSessionInput = RepositoryRef & {
53
+ type CreateSessionOptions = {
43
54
  prompt: string;
44
55
  target: TargetSpec;
45
56
  fallbacks?: TargetSpec[];
@@ -52,11 +63,33 @@ export type CreateSessionInput = RepositoryRef & {
52
63
  metadata?: Record<string, SessionMetadataValue>;
53
64
  /** Defaults to `"api"`; `"ui"`/`"webhook"` pass through, anything else becomes `"api"`. */
54
65
  source?: CreatableSessionSource;
66
+ /** Raw setup scripts are not a session input; select a trusted profile by id. */
67
+ setupScript?: never;
68
+ };
69
+
70
+ /** Host-scoped, non-git create-session input. */
71
+ export type WorkspaceSessionInput = CreateSessionOptions & {
72
+ repositoryId: null;
73
+ workspacePoolId: string;
74
+ setupProfileId?: string;
75
+ destroyWorkspaceAfter?: boolean;
76
+ type?: "workspace";
77
+ ref?: never;
78
+ requiredLabels?: [];
55
79
  };
56
80
 
81
+ /** Existing repository-backed create-session input, retained unchanged. */
82
+ export type CreateSessionInput =
83
+ | (RepositoryRef & CreateSessionOptions & { type?: "prompt" | "scheduled" })
84
+ | WorkspaceSessionInput;
85
+
57
86
  export type Session = {
58
87
  id: string;
59
- repositoryId: string;
88
+ repositoryId: string | null;
89
+ workspacePoolId?: string;
90
+ workspaceSlotId?: string | null;
91
+ setupProfileId?: string;
92
+ destroyWorkspaceAfter?: boolean;
60
93
  prompt: string;
61
94
  target: TargetRef;
62
95
  fallbacks?: TargetRef[];
@@ -75,14 +108,31 @@ export type Session = {
75
108
  createdAt: string;
76
109
  url: string;
77
110
  created?: boolean;
111
+ /** Present on detail reads and terminal action results when available. */
112
+ result?: SessionResult;
113
+ parentSessionId?: string;
114
+ rootSessionId?: string;
78
115
  };
79
116
 
80
- /** Body accepted by `POST /sessions/:id/resume`. */
117
+ export type CreateChildSessionInput = {
118
+ prompt: string;
119
+ spawnKey: string;
120
+ priority?: number;
121
+ queueTtlSeconds?: number;
122
+ };
123
+
124
+ export type ListChildSessionsOptions = { limit?: number; cursor?: string };
125
+
126
+ /** Body accepted by `POST /sessions/:id/resume`. `target`/`fallbacks` are an optional
127
+ * rebinding override — passing `target` alone clears any inherited `fallbacks`, and
128
+ * a bare `fallbacks` without `target` is rejected. */
81
129
  export type ResumeSessionInput = {
82
130
  prompt?: string;
83
131
  concurrencyId?: string;
84
132
  timeout?: number;
85
133
  priority?: number;
134
+ target?: TargetSpec;
135
+ fallbacks?: TargetSpec[];
86
136
  };
87
137
 
88
138
  /** `status` filter accepted by `GET /sessions`. */
@@ -179,6 +229,10 @@ export class AutoHarnessError extends Error {
179
229
  operationId?: string;
180
230
  /** API-relative URL for the drain that fenced this request. */
181
231
  statusUrl?: string;
232
+ /** The complete `body.error` object from the response, when the server returned JSON —
233
+ * e.g. a refused delete's `dependencies` array. Undefined when the response had no parseable
234
+ * JSON `error` object. */
235
+ details?: Record<string, unknown>;
182
236
  constructor(
183
237
  message: string,
184
238
  options: {
@@ -187,6 +241,7 @@ export class AutoHarnessError extends Error {
187
241
  retryAfter?: string;
188
242
  operationId?: string;
189
243
  statusUrl?: string;
244
+ details?: Record<string, unknown>;
190
245
  },
191
246
  );
192
247
  }
@@ -232,6 +287,11 @@ export class AutoHarnessClient {
232
287
  constructor(options: AutoHarnessClientOptions);
233
288
  createSession(input: CreateSessionInput): Promise<Session & { created: boolean }>;
234
289
  getSession(id: string): Promise<Session>;
290
+ createChildSession(
291
+ parentId: string,
292
+ input: CreateChildSessionInput,
293
+ ): Promise<Session & { created: boolean }>;
294
+ listChildSessions(parentId: string, options?: ListChildSessionsOptions): Promise<SessionPage>;
235
295
  cancelSession(id: string): Promise<Session>;
236
296
  resumeSession(id: string, input?: ResumeSessionInput): Promise<Session & { created: boolean }>;
237
297
  listSessions(options?: ListSessionsOptions): Promise<SessionPage>;