auto-harness-client 0.4.0 → 0.6.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,12 @@ 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
+
25
31
  ## Target by provider or command name
26
32
 
27
33
  `target` and `fallbacks` accept a `providerId`/`commandId` as before, or a human-readable
@@ -39,13 +45,34 @@ const session = await harness.createSession({
39
45
  });
40
46
  ```
41
47
 
42
- Provider names are normally unique — server-enforced on create/update but that check is a
43
- read-then-write race, not an atomic constraint, so `providerName` resolution still checks for
44
- more than one match rather than trusting uniqueness. Command names are **not** server-enforced
45
- unique at all. Either way, an unresolvable or ambiguous name throws `AutoHarnessError`
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`
46
53
  (`code === "UNKNOWN_PROVIDER_NAME"`, `"UNKNOWN_COMMAND_NAME"`, `"AMBIGUOUS_PROVIDER_NAME"`, or
47
54
  `"AMBIGUOUS_COMMAND_NAME"`); the ambiguous-name message never includes the matched ids.
48
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
+
49
76
  ## Request deadlines
50
77
 
51
78
  Every request has a deadline that includes receiving and consuming the JSON response body.
@@ -97,6 +124,27 @@ When create, clone, or resume loses to the fence, `AutoHarnessError` has `code =
97
124
  plus the durable `operationId` and API-relative `statusUrl`; follow that operation rather than
98
125
  reimplementing pagination or cancellation reconciliation.
99
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
+
100
148
  ## Resume a session
101
149
 
102
150
  Resume re-runs a previously assigned session. It initially prefers the source host and its stored
@@ -131,3 +179,27 @@ while (page.nextCursor) {
131
179
  sessions.push(...page.items);
132
180
  }
133
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
+ The same module also covers session drain from a workflow: `isHarnessDrainOperation` (a type
196
+ guard for the `start-drain`/`get-drain`/`wait-for-drain`/`release-drain` operation vocabulary) and
197
+ `writeDrainOutputs` (writes `operation-id`/`status`/`queued-count`/`running-count`/
198
+ `cancelled-count`/`failure-code` to `GITHUB_OUTPUT` for a `SessionDrain`).
199
+
200
+ ```js
201
+ import { parseHarnessTarget, requiredEnvironmentValue } from "auto-harness-client/actions";
202
+
203
+ const target = parseHarnessTarget(process.env.HARNESS_TARGET);
204
+ const apiKey = requiredEnvironmentValue(process.env, "HARNESS_API_KEY");
205
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.4.0",
3
+ "version": "0.6.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,8 @@
1
+ export function isHarnessDrainOperation(value) {
2
+ return (
3
+ value === "start-drain" ||
4
+ value === "get-drain" ||
5
+ value === "wait-for-drain" ||
6
+ value === "release-drain"
7
+ );
8
+ }
@@ -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,82 @@
1
+ import type { SessionDrain, 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;
69
+
70
+ export type HarnessDrainOperation =
71
+ | "start-drain"
72
+ | "get-drain"
73
+ | "wait-for-drain"
74
+ | "release-drain";
75
+
76
+ export function isHarnessDrainOperation(value: string | undefined): value is HarnessDrainOperation;
77
+
78
+ /**
79
+ * Writes `operation-id`/`status`/`queued-count`/`running-count`/`cancelled-count`/`failure-code`
80
+ * to `GITHUB_OUTPUT`, only when it is set.
81
+ */
82
+ export function writeDrainOutputs(environment: ActionEnvironment, drain: SessionDrain): void;
@@ -0,0 +1,14 @@
1
+ export { requiredEnvironmentValue } from "./env.js";
2
+ export { HarnessDispatchError } from "./errors.js";
3
+ export { isHarnessDrainOperation } from "./drain.js";
4
+ export {
5
+ parseApiOrigin,
6
+ parseConcurrencyId,
7
+ parseHarnessApiOrigin,
8
+ parseInteger,
9
+ parseMetadata,
10
+ parseRequiredLabels,
11
+ } from "./parse.js";
12
+ export { TARGET_SPEC_KEYS, parseHarnessFallbacks, parseHarnessTarget } from "./parse-target.js";
13
+ export { writeDrainOutputs } from "./write-drain-outputs.js";
14
+ 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,18 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ export function writeDrainOutputs(environment, drain) {
4
+ if (!environment.GITHUB_OUTPUT) return;
5
+ appendFileSync(
6
+ environment.GITHUB_OUTPUT,
7
+ [
8
+ `operation-id=${drain.operationId}`,
9
+ `status=${drain.status}`,
10
+ `queued-count=${drain.queuedCount}`,
11
+ `running-count=${drain.runningCount}`,
12
+ `cancelled-count=${drain.cancelledCount}`,
13
+ `failure-code=${drain.failureCode ?? ""}`,
14
+ "",
15
+ ].join("\n"),
16
+ "utf8",
17
+ );
18
+ }
@@ -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 CHANGED
@@ -18,3 +18,14 @@ export class AutoHarnessRequestTimeoutError extends Error {
18
18
  this.timeoutMs = timeoutMs;
19
19
  }
20
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,3 +1,6 @@
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 };
@@ -7,7 +10,7 @@ export type ProviderRef =
7
10
  | { providerId: string; providerName?: never; commandId?: never; commandName?: never }
8
11
  | { providerName: string; providerId?: never; commandId?: never; commandName?: never };
9
12
 
10
- /** A command target, by id or by `name` — command names are not required to be unique. */
13
+ /** A command target, by id or by `name` — checked defensively for legacy/racy duplicates. */
11
14
  export type CommandRef =
12
15
  | { commandId: string; commandName?: never; providerId?: never; providerName?: never }
13
16
  | { commandName: string; commandId?: never; providerId?: never; providerName?: never };
@@ -19,6 +22,11 @@ export type CommandRef =
19
22
  */
20
23
  export type TargetSpec = ProviderRef | CommandRef;
21
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
+
22
30
  /** Values accepted for a session metadata entry. */
23
31
  export type SessionMetadataValue = string | number | boolean | null;
24
32
 
@@ -31,8 +39,7 @@ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
31
39
  /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
32
40
  export type CreatableSessionSource = "api" | "ui" | "webhook";
33
41
 
34
- export type CreateSessionInput = {
35
- repositoryId: string;
42
+ export type CreateSessionInput = RepositoryRef & {
36
43
  prompt: string;
37
44
  target: TargetSpec;
38
45
  fallbacks?: TargetSpec[];
@@ -137,48 +144,6 @@ export type RepositoryPage = {
137
144
  nextCursor: string | null;
138
145
  };
139
146
 
140
- /** Operator-supplied per-token vendor rates; Auto Harness never fetches vendor prices. */
141
- export type UsageRates = {
142
- inputTokenMicros?: string;
143
- outputTokenMicros?: string;
144
- cachedInputTokenMicros?: string;
145
- reasoningTokenMicros?: string;
146
- currency: string;
147
- };
148
-
149
- /** Global catalog entry: an AI CLI vendor, keyed by a unique, server-enforced `name`. */
150
- export type Provider = {
151
- id: string;
152
- /** e.g. "claude", "codex", "grok" */
153
- name: string;
154
- defaultCommandId: string | null;
155
- createdAt: string;
156
- updatedAt: string;
157
- usageRates?: UsageRates;
158
- };
159
-
160
- /** Bounded literal-prefix policy used by the agent to extract a native resume reference. */
161
- export type ResumeRefCapture = {
162
- stream: "stdout" | "stderr" | "either";
163
- linePrefix: string;
164
- };
165
-
166
- /** Global catalog entry: a named command invocation. `name` is not required to be unique. */
167
- export type Command = {
168
- id: string;
169
- /** e.g. "claude-print", "echo hello world" */
170
- name: string;
171
- argv: string[];
172
- appendPrompt: boolean;
173
- appendPromptSeparator?: boolean;
174
- resumeArgvTemplate?: string[];
175
- resumeRefCapture?: ResumeRefCapture;
176
- /** FK to Provider, or null for a standalone command that runs anywhere ungated. */
177
- providerId: string | null;
178
- createdAt: string;
179
- updatedAt: string;
180
- };
181
-
182
147
  export type SessionDrainStatus = "draining" | "succeeded" | "failed" | "released";
183
148
 
184
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). */
@@ -200,11 +165,11 @@ export type SessionDrain = {
200
165
  };
201
166
 
202
167
  /**
203
- * Thrown for a failed HTTP response, and also, with `status: 400`, when `createSession()`
204
- * cannot resolve a `TargetSpec` name: `code === "UNKNOWN_PROVIDER_NAME"` /
205
- * `"UNKNOWN_COMMAND_NAME"` for no match, `"AMBIGUOUS_PROVIDER_NAME"` /
206
- * `"AMBIGUOUS_COMMAND_NAME"` for more than one match sharing a name — that message never
207
- * includes the matched ids.
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.
208
173
  */
209
174
  export class AutoHarnessError extends Error {
210
175
  status: number;
@@ -232,12 +197,35 @@ export class AutoHarnessRequestTimeoutError extends Error {
232
197
  constructor(timeoutMs: number);
233
198
  }
234
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
+
235
215
  export type AutoHarnessClientOptions = {
236
216
  baseUrl: string;
237
217
  apiKey?: string;
238
218
  fetch?: typeof fetch;
239
219
  /** Per-request deadline in milliseconds (default 30,000; maximum 300,000). */
240
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;
241
229
  };
242
230
 
243
231
  export class AutoHarnessClient {
@@ -249,11 +237,28 @@ export class AutoHarnessClient {
249
237
  listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
250
238
  /** Cancels this principal's own queued/running sessions for one repository and fences new admission from it — not repository or host drain. */
251
239
  startSessionDrain(
252
- repositoryId: string,
240
+ repositoryId: string | RepositoryRef,
253
241
  options?: { idempotencyKey?: string },
254
242
  ): Promise<SessionDrain>;
255
- getSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
256
- 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>;
257
262
  listRepositories(options?: ListRepositoriesOptions): Promise<RepositoryPage>;
258
263
  pauseRepository(id: string): Promise<Repository>;
259
264
  drainRepository(id: string): Promise<Repository>;
package/src/index.js CHANGED
@@ -1,7 +1,13 @@
1
- import { AutoHarnessError, AutoHarnessRequestTimeoutError } from "./errors.js";
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";
2
8
  import { resolveCreateSessionTargets } from "./resolve-target.js";
3
9
 
4
- export { AutoHarnessError, AutoHarnessRequestTimeoutError };
10
+ export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
5
11
 
6
12
  export class AutoHarnessClient {
7
13
  constructor(options) {
@@ -15,6 +21,8 @@ export class AutoHarnessClient {
15
21
  }
16
22
  this.baseUrl = options.baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
17
23
  this.apiKey = options.apiKey;
24
+ this.allowInsecureHttp = Boolean(options.allowInsecureHttp);
25
+ assertSecureTransport(this.baseUrl, this.apiKey, this.allowInsecureHttp);
18
26
  this.fetch = options.fetch ?? globalThis.fetch;
19
27
  if (!this.fetch) throw new TypeError("fetch is required");
20
28
  this.requestTimeoutMs = requestTimeoutMs;
@@ -108,8 +116,9 @@ export class AutoHarnessClient {
108
116
  * Atomically fence this authenticated principal's session admission for one repository and
109
117
  * begin cancelling its existing work. Reuse an idempotency key after an ambiguous retry.
110
118
  */
111
- startSessionDrain(repositoryId, options = {}) {
112
- 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`, {
113
122
  method: "POST",
114
123
  ...(options.idempotencyKey === undefined
115
124
  ? {}
@@ -118,20 +127,78 @@ export class AutoHarnessClient {
118
127
  }
119
128
 
120
129
  /** Get bounded durable progress or terminal proof for one principal session drain. */
121
- getSessionDrain(repositoryId, operationId) {
130
+ async getSessionDrain(repositoryId, operationId) {
131
+ const id = await resolveRepositoryId(this, repositoryId);
122
132
  return this.request(
123
- `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}`,
133
+ `/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}`,
124
134
  );
125
135
  }
126
136
 
127
137
  /** Explicitly reopen admission after a succeeded or failed principal session drain. */
128
- releaseSessionDrain(repositoryId, operationId) {
138
+ async releaseSessionDrain(repositoryId, operationId) {
139
+ const id = await resolveRepositoryId(this, repositoryId);
129
140
  return this.request(
130
- `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}/release`,
141
+ `/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}/release`,
131
142
  { method: "POST" },
132
143
  );
133
144
  }
134
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
+
135
202
  listRepositories(options = {}) {
136
203
  const query = new URLSearchParams();
137
204
  if (options.limit !== undefined) query.set("limit", String(options.limit));
@@ -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
+ }
@@ -1,21 +1,5 @@
1
- import { AutoHarnessError } from "./errors.js";
2
-
3
- async function resolveByName(name, catalog, kind, idKey) {
4
- const matches = (await catalog()).filter((entry) => entry.name === name);
5
- if (matches.length === 0) {
6
- throw new AutoHarnessError(`no ${kind} named "${name}"`, {
7
- status: 400,
8
- code: `UNKNOWN_${kind.toUpperCase()}_NAME`,
9
- });
10
- }
11
- if (matches.length > 1) {
12
- throw new AutoHarnessError(
13
- `ambiguous ${kind} name "${name}": ${matches.length} ${kind}s share this name`,
14
- { status: 400, code: `AMBIGUOUS_${kind.toUpperCase()}_NAME` },
15
- );
16
- }
17
- return { [idKey]: matches[0].id };
18
- }
1
+ import { resolveByName } from "./resolve-by-name.js";
2
+ import { resolveRepositoryId } from "./resolve-repository.js";
19
3
 
20
4
  async function resolveRef(ref, providers, commands) {
21
5
  if (ref == null || typeof ref !== "object") return ref;
@@ -23,21 +7,21 @@ async function resolveRef(ref, providers, commands) {
23
7
  // explicit `providerId: undefined`, which `in` would treat as already resolved.
24
8
  if (ref.providerId !== undefined || ref.commandId !== undefined) return ref;
25
9
  if (ref.providerName !== undefined) {
26
- return resolveByName(ref.providerName, providers, "provider", "providerId");
10
+ return { providerId: await resolveByName(ref.providerName, providers, "provider") };
27
11
  }
28
12
  if (ref.commandName !== undefined) {
29
- return resolveByName(ref.commandName, commands, "command", "commandId");
13
+ return { commandId: await resolveByName(ref.commandName, commands, "command") };
30
14
  }
31
15
  return ref;
32
16
  }
33
17
 
34
18
  /**
35
- * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks` to
36
- * `providerId`/`commandId` via `client.listProviders()`/`listCommands()`, called at most once
37
- * each regardless of how many refs need them. Id-shaped refs pass through untouched, so an
38
- * all-id call makes no extra requests. Provider and command names are both resolved defensively
39
- * against more than one match provider names are server-enforced unique today, but the
40
- * create/update check is a read-then-write race, not an atomic constraint.
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.
41
25
  */
42
26
  export async function resolveCreateSessionTargets(client, input) {
43
27
  let providersPromise;
@@ -46,9 +30,17 @@ export async function resolveCreateSessionTargets(client, input) {
46
30
  const commands = () => (commandsPromise ??= client.listCommands());
47
31
 
48
32
  const target = await resolveRef(input.target, providers, commands);
49
- if (input.fallbacks === undefined) return { ...input, target };
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;
50
42
  const fallbacks = await Promise.all(
51
43
  input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
52
44
  );
53
- return { ...input, target, fallbacks };
45
+ return { ...resolved, fallbacks };
54
46
  }