auto-harness-client 0.6.0 → 0.7.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 +287 -0
- package/package.json +5 -1
- package/src/cli/admin-login.js +71 -0
- package/src/cli/allowlist.js +34 -0
- package/src/cli/args.js +41 -0
- package/src/cli/cli-errors.js +16 -0
- package/src/cli/commands/api.js +91 -0
- package/src/cli/commands/dependency-conflict.js +15 -0
- package/src/cli/commands/doctor.js +164 -0
- package/src/cli/commands/host-drain.js +22 -0
- package/src/cli/commands/host-inventory-get.js +40 -0
- package/src/cli/commands/host-inventory-set.js +66 -0
- package/src/cli/commands/host-inventory.js +15 -0
- package/src/cli/commands/host-list.js +87 -0
- package/src/cli/commands/host-post-action.js +38 -0
- package/src/cli/commands/host-repo-rm.js +128 -0
- package/src/cli/commands/host-repo.js +11 -0
- package/src/cli/commands/host-resume.js +17 -0
- package/src/cli/commands/host.js +25 -0
- package/src/cli/commands/repo-list.js +84 -0
- package/src/cli/commands/repo-rm.js +83 -0
- package/src/cli/commands/repo.js +15 -0
- package/src/cli/commands/service-account-create.js +114 -0
- package/src/cli/commands/service-account-list.js +84 -0
- package/src/cli/commands/service-account-rm.js +48 -0
- package/src/cli/commands/service-account.js +19 -0
- package/src/cli/commands/whoami.js +22 -0
- package/src/cli/config.js +96 -0
- package/src/cli/index.js +21 -0
- package/src/cli/main.js +69 -0
- package/src/cli/path-segment.js +20 -0
- package/src/cli/read-stdin.js +9 -0
- package/src/cli/report-error.js +34 -0
- package/src/cli/service-account-format.js +24 -0
- package/src/cli/usage.js +66 -0
- package/src/errors.js +1 -0
- package/src/index.d.ts +64 -4
- package/src/index.js +76 -30
- package/src/resolve-target.js +29 -13
|
@@ -0,0 +1,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
|
+
}
|
package/src/cli/usage.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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 rm <hostId> <repositoryId> [--dry-run] [--json]
|
|
19
|
+
auto-harness repo list [--limit N] [--cursor C] [--all] [--json]
|
|
20
|
+
auto-harness repo rm <repositoryId> [--json]
|
|
21
|
+
auto-harness service-account list [--limit N] [--cursor C] [--all] [--json]
|
|
22
|
+
auto-harness service-account create --name <name> --role <role> [--bound-host <hostId>]
|
|
23
|
+
[--repositories <id,id,...>] (--key-file <path> | --print-key) [--json]
|
|
24
|
+
auto-harness service-account rm <id> [--json]
|
|
25
|
+
auto-harness help | --help | -h
|
|
26
|
+
|
|
27
|
+
Configuration:
|
|
28
|
+
--api-url <url> Control plane base URL (else HARNESS_API_URL, else HARNESS_API_HTTP)
|
|
29
|
+
--api-key-file <path> Read the API key from a file, trimmed (else HARNESS_API_KEY_FILE)
|
|
30
|
+
--allow-insecure-http Allow a plain http:// baseUrl (loopback only; local dev)
|
|
31
|
+
|
|
32
|
+
The API key is never accepted as a command-line flag: it would land in \`ps\` output and shell
|
|
33
|
+
history. Set the HARNESS_API_KEY environment variable, or point --api-key-file /
|
|
34
|
+
HARNESS_API_KEY_FILE at a file holding it.
|
|
35
|
+
|
|
36
|
+
Admin bootstrap (no API key exists yet):
|
|
37
|
+
--admin-password-stdin Log in as an admin (password piped through stdin) instead of
|
|
38
|
+
using an API key; combine with --admin-username (default: admin)
|
|
39
|
+
--admin-username <name> Admin username for --admin-password-stdin (default: admin)
|
|
40
|
+
|
|
41
|
+
--admin-password-stdin reads the password from stdin (one trailing newline stripped), logs in
|
|
42
|
+
once via POST /auth/login, and carries the session cookie on every later request instead of an
|
|
43
|
+
API key — it never touches argv, shell history, or output. It cannot be combined with an API key
|
|
44
|
+
(--api-key-file, HARNESS_API_KEY, or HARNESS_API_KEY_FILE), nor with a command that also reads
|
|
45
|
+
stdin for its own input (\`api --body-file -\`, \`host inventory set --file -\`).
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
auto-harness whoami
|
|
49
|
+
auto-harness api GET /hosts
|
|
50
|
+
auto-harness api POST /repositories --body '{"name":"org/repo","url":"https://github.com/org/repo"}'
|
|
51
|
+
auto-harness api DELETE /repositories/repo-1 --body-file -
|
|
52
|
+
auto-harness doctor
|
|
53
|
+
auto-harness host list --online
|
|
54
|
+
auto-harness host drain host-1
|
|
55
|
+
auto-harness host inventory get host-1 --json > inventory.json
|
|
56
|
+
auto-harness host repo rm host-1 repo-1 --dry-run
|
|
57
|
+
auto-harness repo list --all
|
|
58
|
+
auto-harness repo rm repo-1
|
|
59
|
+
auto-harness service-account list
|
|
60
|
+
auto-harness service-account create --name ci --role operator --print-key > /dev/null
|
|
61
|
+
KEY=$(auto-harness service-account create --name ci --role operator --print-key)
|
|
62
|
+
aws ssm get-parameter --name /auto-harness/admin-password --with-decryption \\
|
|
63
|
+
--query Parameter.Value --output text \\
|
|
64
|
+
| auto-harness --admin-password-stdin service-account create --name ci --role operator --print-key
|
|
65
|
+
`;
|
|
66
|
+
}
|
package/src/errors.js
CHANGED
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
|
-
|
|
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
|
-
|
|
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>;
|
package/src/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* eslint-disable max-lines -- one small client class covering every REST method is the published surface. */
|
|
1
2
|
import {
|
|
2
3
|
AutoHarnessDrainWaitTimeoutError,
|
|
3
4
|
AutoHarnessError,
|
|
@@ -5,7 +6,7 @@ import {
|
|
|
5
6
|
} from "./errors.js";
|
|
6
7
|
import { assertSecureTransport } from "./loopback.js";
|
|
7
8
|
import { resolveRepositoryId } from "./resolve-repository.js";
|
|
8
|
-
import { resolveCreateSessionTargets } from "./resolve-target.js";
|
|
9
|
+
import { resolveCreateSessionTargets, resolveTargetSpecs } from "./resolve-target.js";
|
|
9
10
|
|
|
10
11
|
export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
|
|
11
12
|
|
|
@@ -64,6 +65,7 @@ export class AutoHarnessClient {
|
|
|
64
65
|
retryAfter: response.headers.get("retry-after") ?? undefined,
|
|
65
66
|
operationId: error?.operationId,
|
|
66
67
|
statusUrl: error?.statusUrl,
|
|
68
|
+
details: error,
|
|
67
69
|
},
|
|
68
70
|
);
|
|
69
71
|
}
|
|
@@ -85,15 +87,42 @@ export class AutoHarnessClient {
|
|
|
85
87
|
return this.request(`/sessions/${encodeURIComponent(id)}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
createChildSession(parentId, input) {
|
|
91
|
+
return this.request(`/sessions/${encodeURIComponent(parentId)}/children`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: JSON.stringify(input),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
listChildSessions(parentId, options = {}) {
|
|
98
|
+
const query = new URLSearchParams();
|
|
99
|
+
if (options.limit !== undefined) query.set("limit", String(options.limit));
|
|
100
|
+
if (options.cursor !== undefined) query.set("cursor", options.cursor);
|
|
101
|
+
const suffix = query.toString();
|
|
102
|
+
return this.request(
|
|
103
|
+
suffix
|
|
104
|
+
? `/sessions/${encodeURIComponent(parentId)}/children?${suffix}`
|
|
105
|
+
: `/sessions/${encodeURIComponent(parentId)}/children`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
88
109
|
cancelSession(id) {
|
|
89
110
|
return this.request(`/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" });
|
|
90
111
|
}
|
|
91
112
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Resume a previously assigned session on its pinned host, native CLI resume where supported.
|
|
115
|
+
* An optional `target`/`fallbacks` override rebinds the resume to a different Command/Provider
|
|
116
|
+
* — a repoint applying on the next resume, instead of only to new dispatches — and its
|
|
117
|
+
* `providerName`/`commandName` sugar is resolved the same way `createSession()` resolves it.
|
|
118
|
+
* Resolution only runs when `target` is present, so an id-only or bodyless resume makes no
|
|
119
|
+
* extra `listCommands()`/`listProviders()` requests.
|
|
120
|
+
*/
|
|
121
|
+
async resumeSession(id, input) {
|
|
122
|
+
const body = input?.target === undefined ? input : await resolveTargetSpecs(this, input);
|
|
94
123
|
return this.request(`/sessions/${encodeURIComponent(id)}/resume`, {
|
|
95
124
|
method: "POST",
|
|
96
|
-
...(
|
|
125
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
97
126
|
});
|
|
98
127
|
}
|
|
99
128
|
|
|
@@ -161,37 +190,40 @@ export class AutoHarnessClient {
|
|
|
161
190
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
162
191
|
throw new TypeError("timeoutMs must be a finite positive number");
|
|
163
192
|
const deadline = Date.now() + timeoutMs;
|
|
164
|
-
const clampedClient = () =>
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
193
|
+
const clampedClient = () => {
|
|
194
|
+
const remainingMs = deadline - Date.now();
|
|
195
|
+
const requestTimeoutMs = Math.max(1, Math.ceil(Math.min(this.requestTimeoutMs, remainingMs)));
|
|
196
|
+
return {
|
|
197
|
+
client: new AutoHarnessClient({
|
|
198
|
+
baseUrl: this.baseUrl,
|
|
199
|
+
apiKey: this.apiKey,
|
|
200
|
+
allowInsecureHttp: this.allowInsecureHttp,
|
|
201
|
+
fetch: this.fetch,
|
|
202
|
+
requestTimeoutMs,
|
|
203
|
+
}),
|
|
204
|
+
clampedToWaitBudget: remainingMs <= this.requestTimeoutMs,
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
const rejectAtDeadline = (error, idForError, clampedToWaitBudget) =>
|
|
208
|
+
error instanceof AutoHarnessRequestTimeoutError &&
|
|
209
|
+
(clampedToWaitBudget || Date.now() >= deadline)
|
|
177
210
|
? new AutoHarnessDrainWaitTimeoutError(idForError, operationId, timeoutMs)
|
|
178
211
|
: error;
|
|
179
212
|
|
|
213
|
+
const repositoryLookup = clampedClient();
|
|
180
214
|
const id = await resolveRepositoryId(
|
|
181
|
-
{ listRepositories: (
|
|
215
|
+
{ listRepositories: (opts) => repositoryLookup.client.listRepositories(opts) },
|
|
182
216
|
repositoryId,
|
|
183
217
|
).catch((error) => {
|
|
184
|
-
throw rejectAtDeadline(error, repositoryId);
|
|
218
|
+
throw rejectAtDeadline(error, repositoryId, repositoryLookup.clampedToWaitBudget);
|
|
185
219
|
});
|
|
186
220
|
for (;;) {
|
|
187
|
-
if (deadline - Date.now() <= 0)
|
|
221
|
+
if (deadline - Date.now() <= 0)
|
|
188
222
|
throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
|
|
189
|
-
}
|
|
190
|
-
const sessionDrain = await
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
throw rejectAtDeadline(error, id);
|
|
194
|
-
});
|
|
223
|
+
const { client, clampedToWaitBudget } = clampedClient();
|
|
224
|
+
const sessionDrain = await client.getSessionDrain(id, operationId).catch((error) => {
|
|
225
|
+
throw rejectAtDeadline(error, id, clampedToWaitBudget);
|
|
226
|
+
});
|
|
195
227
|
if (sessionDrain.status !== "draining") return sessionDrain;
|
|
196
228
|
const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
|
|
197
229
|
if (delayMs <= 0) throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
|
|
@@ -224,12 +256,26 @@ export class AutoHarnessClient {
|
|
|
224
256
|
}
|
|
225
257
|
|
|
226
258
|
async listProviders() {
|
|
227
|
-
|
|
228
|
-
return items;
|
|
259
|
+
return this.listCatalog("/providers");
|
|
229
260
|
}
|
|
230
261
|
|
|
231
262
|
async listCommands() {
|
|
232
|
-
|
|
233
|
-
|
|
263
|
+
return this.listCatalog("/commands");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async listCatalog(path) {
|
|
267
|
+
const items = [];
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
let suffix = "?limit=100";
|
|
270
|
+
for (let page = 0; page < 20; page += 1) {
|
|
271
|
+
const body = await this.request(`${path}${suffix}`);
|
|
272
|
+
items.push(...(body.items ?? []));
|
|
273
|
+
const cursor = body.nextCursor ?? null;
|
|
274
|
+
if (!cursor) return items;
|
|
275
|
+
if (seen.has(cursor)) throw new Error(`repeated pagination cursor for ${path}`);
|
|
276
|
+
seen.add(cursor);
|
|
277
|
+
suffix = `?limit=100&cursor=${encodeURIComponent(cursor)}`;
|
|
278
|
+
}
|
|
279
|
+
throw new Error(`pagination exceeded 20 pages for ${path}`);
|
|
234
280
|
}
|
|
235
281
|
}
|
package/src/resolve-target.js
CHANGED
|
@@ -16,31 +16,47 @@ async function resolveRef(ref, providers, commands) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
19
|
+
* Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks` to their ids
|
|
20
|
+
* via `client.listProviders()`/`listCommands()`, each called at most once regardless of how many
|
|
21
|
+
* refs need it. Id-shaped refs pass through untouched, so an all-id call makes no extra requests.
|
|
22
|
+
* Names are resolved defensively against more than one match: create/update checks are
|
|
23
|
+
* read-then-write races rather than atomic constraints, and legacy catalog rows are not rewritten.
|
|
24
|
+
* Shared by session create and resume — resume has no repository to resolve, so it calls this
|
|
25
|
+
* directly instead of `resolveCreateSessionTargets`.
|
|
25
26
|
*/
|
|
26
|
-
export async function
|
|
27
|
+
export async function resolveTargetSpecs(client, input) {
|
|
27
28
|
let providersPromise;
|
|
28
29
|
let commandsPromise;
|
|
29
30
|
const providers = () => (providersPromise ??= client.listProviders());
|
|
30
31
|
const commands = () => (commandsPromise ??= client.listCommands());
|
|
31
32
|
|
|
32
33
|
const target = await resolveRef(input.target, providers, commands);
|
|
34
|
+
const resolved = { ...input, target };
|
|
35
|
+
if (input.fallbacks === undefined) return resolved;
|
|
36
|
+
const fallbacks = await Promise.all(
|
|
37
|
+
input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
|
|
38
|
+
);
|
|
39
|
+
return { ...resolved, fallbacks };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
|
|
44
|
+
* `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
|
|
45
|
+
* `listRepositories()`.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveCreateSessionTargets(client, input) {
|
|
48
|
+
if (Object.hasOwn(input, "setupScript")) {
|
|
49
|
+
throw new TypeError("setupScript is not accepted; use setupProfileId");
|
|
50
|
+
}
|
|
51
|
+
const withTargets = await resolveTargetSpecs(client, input);
|
|
52
|
+
if (input.repositoryId === null) return withTargets;
|
|
33
53
|
const repositoryId = await resolveRepositoryId(
|
|
34
54
|
client,
|
|
35
55
|
input.repositoryId !== undefined
|
|
36
56
|
? input.repositoryId
|
|
37
57
|
: { repositoryName: input.repositoryName },
|
|
38
58
|
);
|
|
39
|
-
const resolved = { ...
|
|
59
|
+
const resolved = { ...withTargets, repositoryId };
|
|
40
60
|
delete resolved.repositoryName;
|
|
41
|
-
|
|
42
|
-
const fallbacks = await Promise.all(
|
|
43
|
-
input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
|
|
44
|
-
);
|
|
45
|
-
return { ...resolved, fallbacks };
|
|
61
|
+
return resolved;
|
|
46
62
|
}
|