auto-harness-client 0.3.0 → 0.5.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 CHANGED
@@ -22,6 +22,57 @@ const session = await harness.createSession({
22
22
  console.log(session.url);
23
23
  ```
24
24
 
25
+ `baseUrl` must be `https` whenever `apiKey` is set — the constructor throws otherwise. Pass
26
+ `allowInsecureHttp: true` to opt out, but only for a genuine loopback `baseUrl` (`127.0.0.0/8`,
27
+ `::1`, or `localhost`) — the constructor verifies this itself and throws for any other `http:`
28
+ `baseUrl` even when the flag is set. A private-network address (RFC1918) still crosses real network
29
+ hardware and does not qualify.
30
+
31
+ ## Target by provider or command name
32
+
33
+ `target` and `fallbacks` accept a `providerId`/`commandId` as before, or a human-readable
34
+ `providerName`/`commandName`. `createSession()` resolves each name to an id via
35
+ `listProviders()`/`listCommands()` before sending the request — at most one list call per catalog,
36
+ regardless of how many refs need it, and none at all when every ref is already id-based.
37
+
38
+ ```js
39
+ const session = await harness.createSession({
40
+ repositoryId: "repo-1",
41
+ prompt: "Review the latest changes",
42
+ target: { providerName: "codex" },
43
+ fallbacks: [{ commandName: "claude-print-plan" }],
44
+ timeout: 1_800,
45
+ });
46
+ ```
47
+
48
+ Provider and Command names are server-enforced unique slugs within their respective catalogs on
49
+ create/update, but those checks are read-then-write races rather than atomic constraints, and
50
+ legacy catalog rows are not rewritten.
51
+ Name resolution therefore still checks for more than one match rather than trusting uniqueness.
52
+ Either way, an unresolvable or ambiguous name throws `AutoHarnessError`
53
+ (`code === "UNKNOWN_PROVIDER_NAME"`, `"UNKNOWN_COMMAND_NAME"`, `"AMBIGUOUS_PROVIDER_NAME"`, or
54
+ `"AMBIGUOUS_COMMAND_NAME"`); the ambiguous-name message never includes the matched ids.
55
+
56
+ ## Repository by name
57
+
58
+ `createSession()` and the principal session drain methods (`startSessionDrain()`,
59
+ `getSessionDrain()`, `releaseSessionDrain()`, `waitForSessionDrain()`) accept a `RepositoryRef` —
60
+ `{ repositoryId }` as before, or `{ repositoryName }` — wherever they take a `repositoryId`
61
+ parameter. `listSessions()`'s `repositoryId` filter and the repository administration methods
62
+ (`pauseRepository()`, `drainRepository()`, `activateRepository()`) remain id-only. Repositories are
63
+ not exposed as a single unpaginated catalog call, so resolving by name pages through
64
+ `listRepositories()` in full before matching. An unresolvable or ambiguous name throws
65
+ `AutoHarnessError` (`code === "UNKNOWN_REPOSITORY_NAME"` or `"AMBIGUOUS_REPOSITORY_NAME"`).
66
+
67
+ ```js
68
+ const session = await harness.createSession({
69
+ repositoryName: "voucha/filaments",
70
+ prompt: "Review the latest changes",
71
+ target: { providerId: "codex" },
72
+ timeout: 1_800,
73
+ });
74
+ ```
75
+
25
76
  ## Request deadlines
26
77
 
27
78
  Every request has a deadline that includes receiving and consuming the JSON response body.
@@ -45,10 +96,13 @@ while (page.nextCursor) {
45
96
 
46
97
  ## Principal session drains
47
98
 
48
- An automation principal can atomically stop admitting only its own sessions for one repository,
49
- then wait for the control plane to cancel and settle that exact scope. This is not repository or
50
- host drain. Use a stable idempotency key when retries may be ambiguous, poll the durable operation,
51
- and release the fence explicitly only after recording its terminal result.
99
+ Cancels this principal's own queued and running sessions for one repository, then fences new
100
+ admission from that same principal until the fence is explicitly released. **Not** repository
101
+ drain or host drain see
102
+ [Principal session drains](https://github.com/jonathanong/auto-harness/blob/main/docs/api.md#principal-session-drains)
103
+ for the full disambiguation and server-side guarantees. Use a stable idempotency key when retries
104
+ may be ambiguous, poll the durable operation, and release the fence explicitly only after
105
+ recording its terminal result.
52
106
 
53
107
  ```js
54
108
  const drain = await harness.startSessionDrain("repo-1", {
@@ -60,14 +114,37 @@ while (progress.status === "draining") {
60
114
  await new Promise((resolve) => setTimeout(resolve, 5_000));
61
115
  progress = await harness.getSessionDrain("repo-1", drain.operationId);
62
116
  }
63
- if (progress.status !== "succeeded") throw new Error(`Drain failed: ${progress.failureCode}`);
117
+ const failed = progress.status !== "succeeded";
118
+ if (failed) console.error(`Drain failed: ${progress.failureCode}`);
64
119
  await harness.releaseSessionDrain("repo-1", drain.operationId);
120
+ if (failed) throw new Error(`Drain failed: ${progress.failureCode}`);
65
121
  ```
66
122
 
67
123
  When create, clone, or resume loses to the fence, `AutoHarnessError` has `code === "DRAINING"`
68
124
  plus the durable `operationId` and API-relative `statusUrl`; follow that operation rather than
69
125
  reimplementing pagination or cancellation reconciliation.
70
126
 
127
+ `waitForSessionDrain(repositoryId, operationId, { pollIntervalMs, timeoutMs })` replaces the manual
128
+ poll loop above: it resolves `repositoryId`, then polls `getSessionDrain()` until a terminal status,
129
+ clamping every request — including each page fetched to resolve a `repositoryName` — to the time
130
+ remaining before `timeoutMs`. It resolves with the terminal `SessionDrain` for any status, including
131
+ `"failed"` and `"released"` — callers classify success themselves — and rejects with
132
+ `AutoHarnessDrainWaitTimeoutError` (`code === "DRAIN_WAIT_TIMEOUT"`) when the overall `timeoutMs`
133
+ budget elapses — including when a clamped request is the thing that times out at that same instant
134
+ — or with `AutoHarnessRequestTimeoutError` (`code === "REQUEST_TIMEOUT"`) if an individual request
135
+ times out while budget still remains.
136
+
137
+ ```js
138
+ const progress = await harness.waitForSessionDrain("repo-1", drain.operationId, {
139
+ pollIntervalMs: 5_000,
140
+ timeoutMs: 300_000,
141
+ });
142
+ const failed = progress.status !== "succeeded";
143
+ if (failed) console.error(`Drain failed: ${progress.failureCode}`);
144
+ await harness.releaseSessionDrain("repo-1", drain.operationId);
145
+ if (failed) throw new Error(`Drain failed: ${progress.failureCode}`);
146
+ ```
147
+
71
148
  ## Resume a session
72
149
 
73
150
  Resume re-runs a previously assigned session. It initially prefers the source host and its stored
@@ -102,3 +179,22 @@ while (page.nextCursor) {
102
179
  sessions.push(...page.items);
103
180
  }
104
181
  ```
182
+
183
+ ## `auto-harness-client/actions`
184
+
185
+ Dependency-free helpers for authoring a GitHub Action that dispatches Auto Harness sessions from
186
+ `INPUT_*` env vars: `requiredEnvironmentValue`, `parseInteger`, `parseRequiredLabels`,
187
+ `parseConcurrencyId`, `parseMetadata`, `parseHarnessApiOrigin`, `parseHarnessTarget`,
188
+ `parseHarnessFallbacks`, `TARGET_SPEC_KEYS`, `HarnessDispatchError`, and `writeOutputs` (for
189
+ `GITHUB_OUTPUT`/`GITHUB_STEP_SUMMARY`/`::notice` reporting). These parse the same env-var contract
190
+ that `auto-harness`'s own
191
+ [`actions/dispatch`](https://github.com/jonathanong/auto-harness/blob/main/actions/dispatch) action
192
+ uses, so a consuming
193
+ workflow's inline script and a bundled composite action stay in sync with the same validation.
194
+
195
+ ```js
196
+ import { parseHarnessTarget, requiredEnvironmentValue } from "auto-harness-client/actions";
197
+
198
+ const target = parseHarnessTarget(process.env.HARNESS_TARGET);
199
+ const apiKey = requiredEnvironmentValue(process.env, "HARNESS_API_KEY");
200
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Dependency-free client for the Auto Harness automation API",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,14 @@
17
17
  ".": {
18
18
  "types": "./src/index.d.ts",
19
19
  "import": "./src/index.js"
20
+ },
21
+ "./actions": {
22
+ "types": "./src/actions/index.d.ts",
23
+ "import": "./src/actions/index.js"
24
+ },
25
+ "./loopback": {
26
+ "types": "./src/loopback.d.ts",
27
+ "import": "./src/loopback.js"
20
28
  }
21
29
  },
22
30
  "publishConfig": {
@@ -0,0 +1,7 @@
1
+ import { HarnessDispatchError } from "./errors.js";
2
+
3
+ export function requiredEnvironmentValue(environment, key) {
4
+ const value = environment[key]?.trim();
5
+ if (!value) throw new HarnessDispatchError("MISSING_ENVIRONMENT_VALUE", `${key} is required`);
6
+ return value;
7
+ }
@@ -0,0 +1,6 @@
1
+ export class HarnessDispatchError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }
@@ -0,0 +1,68 @@
1
+ import type { TargetSpec } from "../index.js";
2
+
3
+ /** Dependency-free env→`CreateSessionInput` adapter for GitHub Actions consumers of Auto Harness. */
4
+
5
+ export type ActionEnvironment = Record<string, string | undefined>;
6
+
7
+ export class HarnessDispatchError extends Error {
8
+ code: string;
9
+ constructor(code: string, message: string);
10
+ }
11
+
12
+ export function requiredEnvironmentValue(environment: ActionEnvironment, key: string): string;
13
+
14
+ export function parseInteger(
15
+ value: string | undefined,
16
+ key: string,
17
+ minimum: number,
18
+ ): number | undefined;
19
+
20
+ /** Parses `HARNESS_REQUIRED_LABELS`-shaped input; `fieldName` customizes the error message only. */
21
+ export function parseRequiredLabels(value: string | undefined, fieldName?: string): string[];
22
+
23
+ export function parseConcurrencyId(
24
+ value: string | undefined,
25
+ options?: { fieldName?: string; optional?: boolean; allowAnyCharacters?: boolean },
26
+ ): string | undefined;
27
+
28
+ export function parseMetadata(
29
+ value: string | undefined,
30
+ fieldName?: string,
31
+ ): Record<string, string | number | boolean | null>;
32
+
33
+ /**
34
+ * Validates a raw URL string is an exact origin, optionally suffixed with `/api/v1`
35
+ * (accepted and stripped, so an API-relative origin round-trips unchanged). Requires https
36
+ * unless `allowHttp` is set.
37
+ */
38
+ export function parseApiOrigin(
39
+ rawUrl: string,
40
+ options?: { fieldName?: string; allowHttp?: boolean },
41
+ ): URL;
42
+
43
+ /** Validates `HARNESS_URL` is an exact `https://host` origin (no path/query/hash/credentials). */
44
+ export function parseHarnessApiOrigin(environment: ActionEnvironment): URL;
45
+
46
+ export const TARGET_SPEC_KEYS: readonly ["providerId", "providerName", "commandId", "commandName"];
47
+
48
+ export function parseHarnessTarget(value: string, fieldName?: string): TargetSpec;
49
+
50
+ export function parseHarnessFallbacks(value: string | undefined, fieldName?: string): TargetSpec[];
51
+
52
+ export type DispatchResult = {
53
+ id: string;
54
+ url: string;
55
+ created: boolean;
56
+ };
57
+
58
+ /**
59
+ * Writes `session-id`/`session-url`/`created` to `GITHUB_OUTPUT`, a summary table to
60
+ * `GITHUB_STEP_SUMMARY`, and a `::notice` annotation — each only when its env var is set.
61
+ * `route` is the resolved provider/command chain used for this dispatch, if any; omit it for a
62
+ * dispatch that retained an existing session's target.
63
+ */
64
+ export function writeOutputs(
65
+ environment: ActionEnvironment,
66
+ result: DispatchResult,
67
+ route?: TargetSpec[],
68
+ ): void;
@@ -0,0 +1,12 @@
1
+ export { requiredEnvironmentValue } from "./env.js";
2
+ export { HarnessDispatchError } from "./errors.js";
3
+ export {
4
+ parseApiOrigin,
5
+ parseConcurrencyId,
6
+ parseHarnessApiOrigin,
7
+ parseInteger,
8
+ parseMetadata,
9
+ parseRequiredLabels,
10
+ } from "./parse.js";
11
+ export { TARGET_SPEC_KEYS, parseHarnessFallbacks, parseHarnessTarget } from "./parse-target.js";
12
+ export { writeOutputs } from "./write-outputs.js";
@@ -0,0 +1,53 @@
1
+ import { HarnessDispatchError } from "./errors.js";
2
+
3
+ export const TARGET_SPEC_KEYS = ["providerId", "providerName", "commandId", "commandName"];
4
+
5
+ function parseTargetRefValue(parsed, key) {
6
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
7
+ throw new HarnessDispatchError("INVALID_TARGET", `${key} must be a JSON object`);
8
+ }
9
+ const rest = Object.keys(parsed).filter((field) => !TARGET_SPEC_KEYS.includes(field));
10
+ if (rest.length > 0) {
11
+ throw new HarnessDispatchError(
12
+ "INVALID_TARGET",
13
+ `${key} must only contain providerId, providerName, commandId, or commandName`,
14
+ );
15
+ }
16
+ const presentKeys = TARGET_SPEC_KEYS.filter((field) => field in parsed);
17
+ if (presentKeys.length !== 1) {
18
+ throw new HarnessDispatchError(
19
+ "INVALID_TARGET",
20
+ `${key} must be exactly one of providerId, providerName, commandId, or commandName`,
21
+ );
22
+ }
23
+ const field = presentKeys[0];
24
+ const fieldValue = parsed[field];
25
+ if (typeof fieldValue !== "string" || fieldValue === "") {
26
+ throw new HarnessDispatchError("INVALID_TARGET", `${key}.${field} must be a non-empty string`);
27
+ }
28
+ return { [field]: fieldValue };
29
+ }
30
+
31
+ export function parseHarnessTarget(value, fieldName = "HARNESS_TARGET") {
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(value);
35
+ } catch {
36
+ throw new HarnessDispatchError("INVALID_TARGET", `${fieldName} must be valid JSON`);
37
+ }
38
+ return parseTargetRefValue(parsed, fieldName);
39
+ }
40
+
41
+ export function parseHarnessFallbacks(value, fieldName = "HARNESS_FALLBACKS") {
42
+ if (!value?.trim()) return [];
43
+ let parsed;
44
+ try {
45
+ parsed = JSON.parse(value);
46
+ } catch {
47
+ throw new HarnessDispatchError("INVALID_FALLBACKS", `${fieldName} must be valid JSON`);
48
+ }
49
+ if (!Array.isArray(parsed)) {
50
+ throw new HarnessDispatchError("INVALID_FALLBACKS", `${fieldName} must be a JSON array`);
51
+ }
52
+ return parsed.map((entry, index) => parseTargetRefValue(entry, `${fieldName}[${index}]`));
53
+ }
@@ -0,0 +1,144 @@
1
+ import { requiredEnvironmentValue } from "./env.js";
2
+ import { HarnessDispatchError } from "./errors.js";
3
+
4
+ const MAX_CONCURRENCY_ID_CHARACTERS = 128;
5
+ // Mirrors the control plane's own limit (modules/shared/src/validation.ts's
6
+ // MAX_CONCURRENCY_ID_BYTES), which allows any byte sequence up to this length.
7
+ const MAX_CONCURRENCY_ID_BYTES = 2_048;
8
+ const MAX_METADATA_BYTES = 8192;
9
+
10
+ export function parseInteger(value, key, minimum) {
11
+ if (value === undefined || value === "") return undefined;
12
+ if (!/^\d+$/u.test(value) || Number(value) < minimum || !Number.isSafeInteger(Number(value))) {
13
+ throw new HarnessDispatchError(
14
+ "INVALID_INTEGER",
15
+ `${key} must be a ${minimum === 1 ? "positive" : "non-negative"} integer`,
16
+ );
17
+ }
18
+ return Number(value);
19
+ }
20
+
21
+ export function parseRequiredLabels(value, fieldName = "HARNESS_REQUIRED_LABELS") {
22
+ if (!value) return [];
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(value);
26
+ } catch {
27
+ throw new HarnessDispatchError("INVALID_REQUIRED_LABELS", `${fieldName} must be valid JSON`);
28
+ }
29
+ if (
30
+ !Array.isArray(parsed) ||
31
+ !parsed.every((label) => typeof label === "string" && label !== "")
32
+ ) {
33
+ throw new HarnessDispatchError(
34
+ "INVALID_REQUIRED_LABELS",
35
+ `${fieldName} must be a JSON array of strings`,
36
+ );
37
+ }
38
+ return parsed;
39
+ }
40
+
41
+ /**
42
+ * `allowAnyCharacters` widens validation to the control plane's own contract — any byte
43
+ * sequence up to `MAX_CONCURRENCY_ID_BYTES` — for callers (like the dispatch Action) whose
44
+ * concurrency-id may already contain characters the default charset rejects, e.g. a `/`-bearing
45
+ * `${{ github.ref }}`. The default stays narrower (a safe-for-URLs-and-shells charset) for
46
+ * callers with no such existing contract to preserve.
47
+ */
48
+ export function parseConcurrencyId(
49
+ value,
50
+ { fieldName = "HARNESS_CONCURRENCY_ID", optional = false, allowAnyCharacters = false } = {},
51
+ ) {
52
+ const concurrencyId = value?.trim();
53
+ if (!concurrencyId) {
54
+ if (optional) return undefined;
55
+ throw new HarnessDispatchError("INVALID_CONCURRENCY_ID", `${fieldName} is required`);
56
+ }
57
+ if (allowAnyCharacters) {
58
+ if (Buffer.byteLength(concurrencyId, "utf8") > MAX_CONCURRENCY_ID_BYTES) {
59
+ throw new HarnessDispatchError(
60
+ "INVALID_CONCURRENCY_ID",
61
+ `${fieldName} exceeds ${MAX_CONCURRENCY_ID_BYTES} bytes`,
62
+ );
63
+ }
64
+ return concurrencyId;
65
+ }
66
+ if (concurrencyId.length > MAX_CONCURRENCY_ID_CHARACTERS) {
67
+ throw new HarnessDispatchError("INVALID_CONCURRENCY_ID", `${fieldName} exceeds 128 characters`);
68
+ }
69
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/u.test(concurrencyId)) {
70
+ throw new HarnessDispatchError(
71
+ "INVALID_CONCURRENCY_ID",
72
+ `${fieldName} contains unsupported characters`,
73
+ );
74
+ }
75
+ return concurrencyId;
76
+ }
77
+
78
+ export function parseMetadata(value, fieldName = "HARNESS_METADATA") {
79
+ if (!value) return {};
80
+ let parsed;
81
+ try {
82
+ parsed = JSON.parse(value);
83
+ } catch {
84
+ throw new HarnessDispatchError("INVALID_METADATA", `${fieldName} must be valid JSON`);
85
+ }
86
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
87
+ throw new HarnessDispatchError("INVALID_METADATA", `${fieldName} must be a JSON object`);
88
+ }
89
+ for (const [key, fieldValue] of Object.entries(parsed)) {
90
+ const isValidScalar =
91
+ ["string", "boolean"].includes(typeof fieldValue) ||
92
+ fieldValue === null ||
93
+ (typeof fieldValue === "number" && Number.isFinite(fieldValue));
94
+ if (!isValidScalar) {
95
+ throw new HarnessDispatchError(
96
+ "INVALID_METADATA",
97
+ `${fieldName}.${key} must be a string, finite number, boolean, or null`,
98
+ );
99
+ }
100
+ }
101
+ if (Buffer.byteLength(JSON.stringify(parsed), "utf8") > MAX_METADATA_BYTES) {
102
+ throw new HarnessDispatchError("INVALID_METADATA", `${fieldName} exceeds 8192 bytes`);
103
+ }
104
+ return parsed;
105
+ }
106
+
107
+ /**
108
+ * Validates a raw URL string is an exact origin, optionally suffixed with `/api/v1`
109
+ * (accepted and stripped, so an API-relative origin round-trips unchanged). Requires https
110
+ * unless `allowHttp` is set, for callers whose control plane may be reached over plain http
111
+ * (e.g. a local or self-hosted deployment without TLS termination).
112
+ */
113
+ export function parseApiOrigin(rawUrl, { fieldName = "HARNESS_URL", allowHttp = false } = {}) {
114
+ let apiUrl;
115
+ try {
116
+ apiUrl = new URL(rawUrl);
117
+ } catch {
118
+ throw new HarnessDispatchError("INVALID_HARNESS_URL", `${fieldName} must be a valid URL`);
119
+ }
120
+ const protocolLabel = allowHttp ? "http or https" : "https";
121
+ if (apiUrl.protocol !== "https:" && !(allowHttp && apiUrl.protocol === "http:")) {
122
+ throw new HarnessDispatchError("INVALID_HARNESS_URL", `${fieldName} must use ${protocolLabel}`);
123
+ }
124
+ const { origin } = apiUrl;
125
+ if (
126
+ apiUrl.username !== "" ||
127
+ apiUrl.password !== "" ||
128
+ apiUrl.search !== "" ||
129
+ apiUrl.hash !== "" ||
130
+ ![origin, `${origin}/`, `${origin}/api/v1`, `${origin}/api/v1/`].includes(rawUrl)
131
+ ) {
132
+ throw new HarnessDispatchError(
133
+ "INVALID_HARNESS_URL",
134
+ `${fieldName} must be an exact ${protocolLabel} origin, optionally suffixed with /api/v1`,
135
+ );
136
+ }
137
+ return new URL(origin);
138
+ }
139
+
140
+ export function parseHarnessApiOrigin(environment) {
141
+ return parseApiOrigin(requiredEnvironmentValue(environment, "HARNESS_URL"), {
142
+ fieldName: "HARNESS_URL",
143
+ });
144
+ }
@@ -0,0 +1,42 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ function formatTargetRef(ref) {
4
+ if ("providerId" in ref) return `\`${ref.providerId}\``;
5
+ if ("providerName" in ref) return `\`${ref.providerName}\``;
6
+ if ("commandId" in ref) return `\`${ref.commandId}\``;
7
+ return `\`${ref.commandName}\``;
8
+ }
9
+
10
+ export function writeOutputs(environment, result, route) {
11
+ if (environment.GITHUB_OUTPUT) {
12
+ appendFileSync(
13
+ environment.GITHUB_OUTPUT,
14
+ `session-id=${result.id}\nsession-url=${result.url}\ncreated=${result.created}\n`,
15
+ "utf8",
16
+ );
17
+ }
18
+ if (environment.GITHUB_STEP_SUMMARY) {
19
+ const routeText = route?.length
20
+ ? route.map(formatTargetRef).join(" → ")
21
+ : "retained from the existing session";
22
+ const concurrencyId = environment.HARNESS_CONCURRENCY_ID?.trim();
23
+ appendFileSync(
24
+ environment.GITHUB_STEP_SUMMARY,
25
+ [
26
+ "## Auto Harness dispatch",
27
+ "",
28
+ `- Session: [${result.id}](${result.url})`,
29
+ `- Created: ${result.created ? "yes" : "no"}`,
30
+ ...(concurrencyId ? [`- Concurrency: \`${concurrencyId}\``] : []),
31
+ `- Provider route: ${routeText}`,
32
+ "",
33
+ ].join("\n"),
34
+ "utf8",
35
+ );
36
+ }
37
+ if (environment.GITHUB_ACTIONS === "true") {
38
+ process.stdout.write(
39
+ `::notice title=Auto Harness session::${result.id} ${result.created ? "created" : "reused"} ${result.url}\n`,
40
+ );
41
+ }
42
+ }
@@ -0,0 +1,41 @@
1
+ /** Operator-supplied per-token vendor rates; Auto Harness never fetches vendor prices. */
2
+ export type UsageRates = {
3
+ inputTokenMicros?: string;
4
+ outputTokenMicros?: string;
5
+ cachedInputTokenMicros?: string;
6
+ reasoningTokenMicros?: string;
7
+ currency: string;
8
+ };
9
+
10
+ /** Global catalog entry: an AI CLI vendor, keyed by a unique, server-enforced `name`. */
11
+ export type Provider = {
12
+ id: string;
13
+ /** e.g. "claude", "codex", "grok" */
14
+ name: string;
15
+ defaultCommandId: string | null;
16
+ createdAt: string;
17
+ updatedAt: string;
18
+ usageRates?: UsageRates;
19
+ };
20
+
21
+ /** Bounded literal-prefix policy used by the agent to extract a native resume reference. */
22
+ export type ResumeRefCapture = {
23
+ stream: "stdout" | "stderr" | "either";
24
+ linePrefix: string;
25
+ };
26
+
27
+ /** Global catalog entry: a named command invocation. New and renamed names are catalog-unique slugs. */
28
+ export type Command = {
29
+ id: string;
30
+ /** e.g. "claude-print", "echo-hello-world" */
31
+ name: string;
32
+ argv: string[];
33
+ appendPrompt: boolean;
34
+ appendPromptSeparator?: boolean;
35
+ resumeArgvTemplate?: string[];
36
+ resumeRefCapture?: ResumeRefCapture;
37
+ /** FK to Provider, or null for a standalone command that runs anywhere ungated. */
38
+ providerId: string | null;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ };
package/src/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ export class AutoHarnessError extends Error {
2
+ constructor(message, options) {
3
+ super(message);
4
+ this.name = "AutoHarnessError";
5
+ this.status = options.status;
6
+ this.code = options.code;
7
+ this.retryAfter = options.retryAfter;
8
+ this.operationId = options.operationId;
9
+ this.statusUrl = options.statusUrl;
10
+ }
11
+ }
12
+
13
+ export class AutoHarnessRequestTimeoutError extends Error {
14
+ constructor(timeoutMs) {
15
+ super(`Auto Harness request timed out after ${timeoutMs}ms`);
16
+ this.name = "AutoHarnessRequestTimeoutError";
17
+ this.code = "REQUEST_TIMEOUT";
18
+ this.timeoutMs = timeoutMs;
19
+ }
20
+ }
21
+
22
+ export class AutoHarnessDrainWaitTimeoutError extends Error {
23
+ constructor(repositoryId, operationId, timeoutMs) {
24
+ super(`Auto Harness session drain wait timed out after ${timeoutMs}ms`);
25
+ this.name = "AutoHarnessDrainWaitTimeoutError";
26
+ this.code = "DRAIN_WAIT_TIMEOUT";
27
+ this.repositoryId = repositoryId;
28
+ this.operationId = operationId;
29
+ this.timeoutMs = timeoutMs;
30
+ }
31
+ }
package/src/index.d.ts CHANGED
@@ -1,7 +1,32 @@
1
+ import type { Command, Provider } from "./catalog-types.js";
2
+ export type { Command, Provider, ResumeRefCapture, UsageRates } from "./catalog-types.js";
3
+
1
4
  export type TargetRef =
2
5
  | { commandId: string; providerId?: never }
3
6
  | { providerId: string; commandId?: never };
4
7
 
8
+ /** A provider target, by id or by `name` — normally unique, checked defensively either way. */
9
+ export type ProviderRef =
10
+ | { providerId: string; providerName?: never; commandId?: never; commandName?: never }
11
+ | { providerName: string; providerId?: never; commandId?: never; commandName?: never };
12
+
13
+ /** A command target, by id or by `name` — checked defensively for legacy/racy duplicates. */
14
+ export type CommandRef =
15
+ | { commandId: string; commandName?: never; providerId?: never; providerName?: never }
16
+ | { commandName: string; commandId?: never; providerId?: never; providerName?: never };
17
+
18
+ /**
19
+ * Input-only target shape for `createSession()`: an id (as `TargetRef`) or a name.
20
+ * `createSession()` resolves a name to its id via `listProviders()`/`listCommands()` before
21
+ * sending the request; a name throws on no match or on more than one match sharing that name.
22
+ */
23
+ export type TargetSpec = ProviderRef | CommandRef;
24
+
25
+ /** A repository target, by id or by unique `name` — checked defensively for legacy/racy duplicates. */
26
+ export type RepositoryRef =
27
+ | { repositoryId: string; repositoryName?: never }
28
+ | { repositoryName: string; repositoryId?: never };
29
+
5
30
  /** Values accepted for a session metadata entry. */
6
31
  export type SessionMetadataValue = string | number | boolean | null;
7
32
 
@@ -14,11 +39,10 @@ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
14
39
  /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
15
40
  export type CreatableSessionSource = "api" | "ui" | "webhook";
16
41
 
17
- export type CreateSessionInput = {
18
- repositoryId: string;
42
+ export type CreateSessionInput = RepositoryRef & {
19
43
  prompt: string;
20
- target: TargetRef;
21
- fallbacks?: TargetRef[];
44
+ target: TargetSpec;
45
+ fallbacks?: TargetSpec[];
22
46
  ref?: string;
23
47
  concurrencyId?: string;
24
48
  queueTtlSeconds?: number;
@@ -122,7 +146,7 @@ export type RepositoryPage = {
122
146
 
123
147
  export type SessionDrainStatus = "draining" | "succeeded" | "failed" | "released";
124
148
 
125
- /** Bounded, durable progress for the authenticated principal's repository session drain. */
149
+ /** Bounded, durable progress for a principal session drain: cancels the authenticated principal's own queued/running sessions for one repository (not repository or host drain). */
126
150
  export type SessionDrain = {
127
151
  operationId: string;
128
152
  repositoryId: string;
@@ -140,6 +164,13 @@ export type SessionDrain = {
140
164
  failureCode?: string;
141
165
  };
142
166
 
167
+ /**
168
+ * Thrown for a failed HTTP response, and also, with `status: 400`, when `createSession()` (or a
169
+ * drain method) cannot resolve a `TargetSpec` or `RepositoryRef` name: `code ===
170
+ * "UNKNOWN_PROVIDER_NAME"` / `"UNKNOWN_COMMAND_NAME"` / `"UNKNOWN_REPOSITORY_NAME"` for no match,
171
+ * `"AMBIGUOUS_PROVIDER_NAME"` / `"AMBIGUOUS_COMMAND_NAME"` / `"AMBIGUOUS_REPOSITORY_NAME"` for
172
+ * more than one match sharing a name — that message never includes the matched ids.
173
+ */
143
174
  export class AutoHarnessError extends Error {
144
175
  status: number;
145
176
  code: string;
@@ -166,12 +197,35 @@ export class AutoHarnessRequestTimeoutError extends Error {
166
197
  constructor(timeoutMs: number);
167
198
  }
168
199
 
200
+ /**
201
+ * Thrown by `waitForSessionDrain()` when its overall `timeoutMs` budget elapses.
202
+ *
203
+ * `repositoryId` echoes back whatever was passed to `waitForSessionDrain()`: a plain
204
+ * repository id string, or the `RepositoryRef` (e.g. `{ repositoryName }`) that was still
205
+ * unresolved when the deadline hit.
206
+ */
207
+ export class AutoHarnessDrainWaitTimeoutError extends Error {
208
+ code: "DRAIN_WAIT_TIMEOUT";
209
+ repositoryId: string | RepositoryRef;
210
+ operationId: string;
211
+ timeoutMs: number;
212
+ constructor(repositoryId: string | RepositoryRef, operationId: string, timeoutMs: number);
213
+ }
214
+
169
215
  export type AutoHarnessClientOptions = {
170
216
  baseUrl: string;
171
217
  apiKey?: string;
172
218
  fetch?: typeof fetch;
173
219
  /** Per-request deadline in milliseconds (default 30,000; maximum 300,000). */
174
220
  requestTimeoutMs?: number;
221
+ /**
222
+ * Allows a non-`https` `baseUrl` while `apiKey` is set, but only when `baseUrl`'s host is
223
+ * genuine loopback (127.0.0.0/8, `::1`, or `localhost`) — see `isLoopbackOrigin()` in
224
+ * `auto-harness-client/loopback`. The constructor independently verifies this and throws for
225
+ * any non-loopback `http:` baseUrl even when this is `true`; a private-network address
226
+ * (RFC1918) still crosses real network hardware and is not loopback. Defaults to `false`.
227
+ */
228
+ allowInsecureHttp?: boolean;
175
229
  };
176
230
 
177
231
  export class AutoHarnessClient {
@@ -181,14 +235,34 @@ export class AutoHarnessClient {
181
235
  cancelSession(id: string): Promise<Session>;
182
236
  resumeSession(id: string, input?: ResumeSessionInput): Promise<Session & { created: boolean }>;
183
237
  listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
238
+ /** Cancels this principal's own queued/running sessions for one repository and fences new admission from it — not repository or host drain. */
184
239
  startSessionDrain(
185
- repositoryId: string,
240
+ repositoryId: string | RepositoryRef,
186
241
  options?: { idempotencyKey?: string },
187
242
  ): Promise<SessionDrain>;
188
- getSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
189
- releaseSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
243
+ getSessionDrain(repositoryId: string | RepositoryRef, operationId: string): Promise<SessionDrain>;
244
+ releaseSessionDrain(
245
+ repositoryId: string | RepositoryRef,
246
+ operationId: string,
247
+ ): Promise<SessionDrain>;
248
+ /**
249
+ * Resolves `repositoryId`, then polls `getSessionDrain()` until it reports a terminal status,
250
+ * clamping every request — including each page fetched to resolve a `repositoryName` — to the
251
+ * time remaining before `timeoutMs`. Resolves with the terminal `SessionDrain` for any status;
252
+ * callers classify success themselves. Rejects with `AutoHarnessDrainWaitTimeoutError` when the
253
+ * overall `timeoutMs` budget elapses — including when a clamped request is the thing that
254
+ * times out at that same instant — or with `AutoHarnessRequestTimeoutError` if an individual
255
+ * request times out while budget still remains.
256
+ */
257
+ waitForSessionDrain(
258
+ repositoryId: string | RepositoryRef,
259
+ operationId: string,
260
+ options: { pollIntervalMs: number; timeoutMs: number },
261
+ ): Promise<SessionDrain>;
190
262
  listRepositories(options?: ListRepositoriesOptions): Promise<RepositoryPage>;
191
263
  pauseRepository(id: string): Promise<Repository>;
192
264
  drainRepository(id: string): Promise<Repository>;
193
265
  activateRepository(id: string): Promise<Repository>;
266
+ listProviders(): Promise<Provider[]>;
267
+ listCommands(): Promise<Command[]>;
194
268
  }
package/src/index.js CHANGED
@@ -1,23 +1,13 @@
1
- export class AutoHarnessError extends Error {
2
- constructor(message, options) {
3
- super(message);
4
- this.name = "AutoHarnessError";
5
- this.status = options.status;
6
- this.code = options.code;
7
- this.retryAfter = options.retryAfter;
8
- this.operationId = options.operationId;
9
- this.statusUrl = options.statusUrl;
10
- }
11
- }
1
+ import {
2
+ AutoHarnessDrainWaitTimeoutError,
3
+ AutoHarnessError,
4
+ AutoHarnessRequestTimeoutError,
5
+ } from "./errors.js";
6
+ import { assertSecureTransport } from "./loopback.js";
7
+ import { resolveRepositoryId } from "./resolve-repository.js";
8
+ import { resolveCreateSessionTargets } from "./resolve-target.js";
12
9
 
13
- export class AutoHarnessRequestTimeoutError extends Error {
14
- constructor(timeoutMs) {
15
- super(`Auto Harness request timed out after ${timeoutMs}ms`);
16
- this.name = "AutoHarnessRequestTimeoutError";
17
- this.code = "REQUEST_TIMEOUT";
18
- this.timeoutMs = timeoutMs;
19
- }
20
- }
10
+ export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
21
11
 
22
12
  export class AutoHarnessClient {
23
13
  constructor(options) {
@@ -31,6 +21,8 @@ export class AutoHarnessClient {
31
21
  }
32
22
  this.baseUrl = options.baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
33
23
  this.apiKey = options.apiKey;
24
+ this.allowInsecureHttp = Boolean(options.allowInsecureHttp);
25
+ assertSecureTransport(this.baseUrl, this.apiKey, this.allowInsecureHttp);
34
26
  this.fetch = options.fetch ?? globalThis.fetch;
35
27
  if (!this.fetch) throw new TypeError("fetch is required");
36
28
  this.requestTimeoutMs = requestTimeoutMs;
@@ -84,8 +76,9 @@ export class AutoHarnessClient {
84
76
  }
85
77
  }
86
78
 
87
- createSession(input) {
88
- return this.request("/sessions", { method: "POST", body: JSON.stringify(input) });
79
+ async createSession(input) {
80
+ const body = await resolveCreateSessionTargets(this, input);
81
+ return this.request("/sessions", { method: "POST", body: JSON.stringify(body) });
89
82
  }
90
83
 
91
84
  getSession(id) {
@@ -123,8 +116,9 @@ export class AutoHarnessClient {
123
116
  * Atomically fence this authenticated principal's session admission for one repository and
124
117
  * begin cancelling its existing work. Reuse an idempotency key after an ambiguous retry.
125
118
  */
126
- startSessionDrain(repositoryId, options = {}) {
127
- return this.request(`/repositories/${encodeURIComponent(repositoryId)}/session-drains`, {
119
+ async startSessionDrain(repositoryId, options = {}) {
120
+ const id = await resolveRepositoryId(this, repositoryId);
121
+ return this.request(`/repositories/${encodeURIComponent(id)}/session-drains`, {
128
122
  method: "POST",
129
123
  ...(options.idempotencyKey === undefined
130
124
  ? {}
@@ -133,20 +127,78 @@ export class AutoHarnessClient {
133
127
  }
134
128
 
135
129
  /** Get bounded durable progress or terminal proof for one principal session drain. */
136
- getSessionDrain(repositoryId, operationId) {
130
+ async getSessionDrain(repositoryId, operationId) {
131
+ const id = await resolveRepositoryId(this, repositoryId);
137
132
  return this.request(
138
- `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}`,
133
+ `/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}`,
139
134
  );
140
135
  }
141
136
 
142
137
  /** Explicitly reopen admission after a succeeded or failed principal session drain. */
143
- releaseSessionDrain(repositoryId, operationId) {
138
+ async releaseSessionDrain(repositoryId, operationId) {
139
+ const id = await resolveRepositoryId(this, repositoryId);
144
140
  return this.request(
145
- `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}/release`,
141
+ `/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}/release`,
146
142
  { method: "POST" },
147
143
  );
148
144
  }
149
145
 
146
+ /**
147
+ * Resolves `repositoryId`, then polls `getSessionDrain()` until it reports a terminal status,
148
+ * clamping every request — including each page fetched to resolve a `repositoryName` — to the
149
+ * time remaining before `timeoutMs`, recomputed fresh per request, so no single request can
150
+ * outlive the overall wait. Resolves with the terminal `SessionDrain` for any status, including
151
+ * "failed" and "released" — callers classify success themselves. Rejects with
152
+ * `AutoHarnessDrainWaitTimeoutError` when the overall `timeoutMs` budget elapses — including
153
+ * when a clamped request is the thing that times out at that same instant — or with
154
+ * `AutoHarnessRequestTimeoutError` if an individual request times out while budget still
155
+ * remains (e.g. a slow server response, well inside `timeoutMs`).
156
+ */
157
+ async waitForSessionDrain(repositoryId, operationId, options = {}) {
158
+ const { pollIntervalMs, timeoutMs } = options;
159
+ if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0)
160
+ throw new TypeError("pollIntervalMs must be a finite positive number");
161
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
162
+ throw new TypeError("timeoutMs must be a finite positive number");
163
+ const deadline = Date.now() + timeoutMs;
164
+ const clampedClient = () =>
165
+ new AutoHarnessClient({
166
+ baseUrl: this.baseUrl,
167
+ apiKey: this.apiKey,
168
+ allowInsecureHttp: this.allowInsecureHttp,
169
+ fetch: this.fetch,
170
+ requestTimeoutMs: Math.max(
171
+ 1,
172
+ Math.ceil(Math.min(this.requestTimeoutMs, deadline - Date.now())),
173
+ ),
174
+ });
175
+ const rejectAtDeadline = (error, idForError) =>
176
+ error instanceof AutoHarnessRequestTimeoutError && Date.now() >= deadline
177
+ ? new AutoHarnessDrainWaitTimeoutError(idForError, operationId, timeoutMs)
178
+ : error;
179
+
180
+ const id = await resolveRepositoryId(
181
+ { listRepositories: (listOptions) => clampedClient().listRepositories(listOptions) },
182
+ repositoryId,
183
+ ).catch((error) => {
184
+ throw rejectAtDeadline(error, repositoryId);
185
+ });
186
+ for (;;) {
187
+ if (deadline - Date.now() <= 0) {
188
+ throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
189
+ }
190
+ const sessionDrain = await clampedClient()
191
+ .getSessionDrain(id, operationId)
192
+ .catch((error) => {
193
+ throw rejectAtDeadline(error, id);
194
+ });
195
+ if (sessionDrain.status !== "draining") return sessionDrain;
196
+ const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
197
+ if (delayMs <= 0) throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
198
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
199
+ }
200
+ }
201
+
150
202
  listRepositories(options = {}) {
151
203
  const query = new URLSearchParams();
152
204
  if (options.limit !== undefined) query.set("limit", String(options.limit));
@@ -170,4 +222,14 @@ export class AutoHarnessClient {
170
222
  repositoryOperation(id, operation) {
171
223
  return this.request(`/repositories/${encodeURIComponent(id)}/${operation}`, { method: "POST" });
172
224
  }
225
+
226
+ async listProviders() {
227
+ const { items } = await this.request("/providers");
228
+ return items;
229
+ }
230
+
231
+ async listCommands() {
232
+ const { items } = await this.request("/commands");
233
+ return items;
234
+ }
173
235
  }
@@ -0,0 +1,5 @@
1
+ /** True when `hostname` (as normalized by `URL#hostname`) is genuine loopback. */
2
+ export function isLoopbackHostname(hostname: string): boolean;
3
+
4
+ /** Loopback check for a `baseUrl` string; an unparseable URL is treated as non-loopback. */
5
+ export function isLoopbackOrigin(rawUrl: string): boolean;
@@ -0,0 +1,43 @@
1
+ function isLoopbackIpv4(hostname) {
2
+ const octets = hostname.split(".");
3
+ if (
4
+ octets.length !== 4 ||
5
+ !octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255)
6
+ ) {
7
+ return false;
8
+ }
9
+ return Number(octets[0]) === 127;
10
+ }
11
+
12
+ /**
13
+ * True when `hostname` (as normalized by `URL#hostname`, e.g. lowercased, IPv6
14
+ * bracketed as `[::1]`) can never leave the local machine's kernel: the IPv4
15
+ * loopback block 127.0.0.0/8, the IPv6 loopback address `::1`, or `localhost`
16
+ * (RFC 6761 §6.3 reserves this name to always resolve to loopback).
17
+ *
18
+ * RFC1918 private addresses and other custom hostnames are deliberately excluded:
19
+ * they still cross real network hardware (switches, VPN, VPC peering) where
20
+ * plaintext credentials can be sniffed, and trusting a caller's claim that a
21
+ * hostname "resolves to loopback" would just reintroduce an unverified bypass.
22
+ */
23
+ export function isLoopbackHostname(hostname) {
24
+ return hostname === "localhost" || hostname === "[::1]" || isLoopbackIpv4(hostname);
25
+ }
26
+
27
+ /** Loopback check for a `baseUrl` string; an unparseable URL is treated as non-loopback. */
28
+ export function isLoopbackOrigin(rawUrl) {
29
+ try {
30
+ return isLoopbackHostname(new URL(rawUrl).hostname);
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ /** Throws when `apiKey` would travel over plain HTTP to a non-loopback `baseUrl`. */
37
+ export function assertSecureTransport(baseUrl, apiKey, allowInsecureHttp) {
38
+ if (!apiKey || baseUrl.startsWith("https://")) return;
39
+ if (allowInsecureHttp && isLoopbackOrigin(baseUrl)) return;
40
+ throw new TypeError(
41
+ "baseUrl must use https when apiKey is set (allowInsecureHttp only permits plain HTTP to a loopback baseUrl, e.g. http://127.0.0.1 or http://localhost)",
42
+ );
43
+ }
@@ -0,0 +1,25 @@
1
+ import { AutoHarnessError } from "./errors.js";
2
+
3
+ /**
4
+ * Resolves a unique `name` to its `id` within a catalog loaded by `catalog()`. Catalog rows are
5
+ * checked defensively for more than one match: create/update name-uniqueness checks are
6
+ * read-then-write races rather than atomic constraints, and legacy rows are not rewritten.
7
+ */
8
+ export async function resolveByName(name, catalog, kind) {
9
+ const matches = (await catalog()).filter((entry) => entry.name === name);
10
+ if (matches.length === 0) {
11
+ throw new AutoHarnessError(`no ${kind} named "${name}"`, {
12
+ status: 400,
13
+ code: `UNKNOWN_${kind.toUpperCase()}_NAME`,
14
+ });
15
+ }
16
+ if (matches.length > 1) {
17
+ throw new AutoHarnessError(
18
+ `ambiguous ${kind} name "${name}": ${matches.length} ${
19
+ kind === "repository" ? "repositories" : `${kind}s`
20
+ } share this name`,
21
+ { status: 400, code: `AMBIGUOUS_${kind.toUpperCase()}_NAME` },
22
+ );
23
+ }
24
+ return matches[0].id;
25
+ }
@@ -0,0 +1,23 @@
1
+ import { resolveByName } from "./resolve-by-name.js";
2
+
3
+ async function listAllRepositories(client) {
4
+ const items = [];
5
+ let page = await client.listRepositories();
6
+ items.push(...page.items);
7
+ while (page.nextCursor) {
8
+ page = await client.listRepositories({ cursor: page.nextCursor });
9
+ items.push(...page.items);
10
+ }
11
+ return items;
12
+ }
13
+
14
+ /**
15
+ * Resolves a `repositoryId` string or a `RepositoryRef` to a `repositoryId`. Unlike providers and
16
+ * commands, repositories are not exposed as a single unpaginated catalog call, so resolving by
17
+ * `repositoryName` pages through `listRepositories()` in full before matching.
18
+ */
19
+ export async function resolveRepositoryId(client, ref) {
20
+ if (typeof ref === "string") return ref;
21
+ if (ref.repositoryId !== undefined) return ref.repositoryId;
22
+ return resolveByName(ref.repositoryName, () => listAllRepositories(client), "repository");
23
+ }
@@ -0,0 +1,46 @@
1
+ import { resolveByName } from "./resolve-by-name.js";
2
+ import { resolveRepositoryId } from "./resolve-repository.js";
3
+
4
+ async function resolveRef(ref, providers, commands) {
5
+ if (ref == null || typeof ref !== "object") return ref;
6
+ // Checked by value, not `in`: a name ref built by conditional spreading can carry an
7
+ // explicit `providerId: undefined`, which `in` would treat as already resolved.
8
+ if (ref.providerId !== undefined || ref.commandId !== undefined) return ref;
9
+ if (ref.providerName !== undefined) {
10
+ return { providerId: await resolveByName(ref.providerName, providers, "provider") };
11
+ }
12
+ if (ref.commandName !== undefined) {
13
+ return { commandId: await resolveByName(ref.commandName, commands, "command") };
14
+ }
15
+ return ref;
16
+ }
17
+
18
+ /**
19
+ * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
20
+ * `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
21
+ * `listRepositories()`, each called at most once regardless of how many refs need it. Id-shaped
22
+ * refs pass through untouched, so an all-id call makes no extra requests. Names are resolved
23
+ * defensively against more than one match: create/update checks are read-then-write races rather
24
+ * than atomic constraints, and legacy catalog rows are not rewritten.
25
+ */
26
+ export async function resolveCreateSessionTargets(client, input) {
27
+ let providersPromise;
28
+ let commandsPromise;
29
+ const providers = () => (providersPromise ??= client.listProviders());
30
+ const commands = () => (commandsPromise ??= client.listCommands());
31
+
32
+ const target = await resolveRef(input.target, providers, commands);
33
+ const repositoryId = await resolveRepositoryId(
34
+ client,
35
+ input.repositoryId !== undefined
36
+ ? input.repositoryId
37
+ : { repositoryName: input.repositoryName },
38
+ );
39
+ const resolved = { ...input, repositoryId, target };
40
+ delete resolved.repositoryName;
41
+ if (input.fallbacks === undefined) return resolved;
42
+ const fallbacks = await Promise.all(
43
+ input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
44
+ );
45
+ return { ...resolved, fallbacks };
46
+ }