auto-harness-client 0.1.0 → 0.4.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
@@ -9,13 +9,125 @@ import { AutoHarnessClient } from "auto-harness-client";
9
9
  const harness = new AutoHarnessClient({
10
10
  baseUrl: process.env.AUTO_HARNESS_URL,
11
11
  apiKey: process.env.AUTO_HARNESS_API_KEY,
12
+ requestTimeoutMs: 30_000,
12
13
  });
13
14
 
14
15
  const session = await harness.createSession({
15
16
  repositoryId: "repo-1",
16
17
  prompt: "Review the latest changes",
17
18
  target: { providerId: "codex" },
19
+ timeout: 1_800,
18
20
  concurrencyId: `github-${process.env.GITHUB_RUN_ID}`,
19
21
  });
20
22
  console.log(session.url);
21
23
  ```
24
+
25
+ ## Target by provider or command name
26
+
27
+ `target` and `fallbacks` accept a `providerId`/`commandId` as before, or a human-readable
28
+ `providerName`/`commandName`. `createSession()` resolves each name to an id via
29
+ `listProviders()`/`listCommands()` before sending the request — at most one list call per catalog,
30
+ regardless of how many refs need it, and none at all when every ref is already id-based.
31
+
32
+ ```js
33
+ const session = await harness.createSession({
34
+ repositoryId: "repo-1",
35
+ prompt: "Review the latest changes",
36
+ target: { providerName: "codex" },
37
+ fallbacks: [{ commandName: "claude-print-plan" }],
38
+ timeout: 1_800,
39
+ });
40
+ ```
41
+
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`
46
+ (`code === "UNKNOWN_PROVIDER_NAME"`, `"UNKNOWN_COMMAND_NAME"`, `"AMBIGUOUS_PROVIDER_NAME"`, or
47
+ `"AMBIGUOUS_COMMAND_NAME"`); the ambiguous-name message never includes the matched ids.
48
+
49
+ ## Request deadlines
50
+
51
+ Every request has a deadline that includes receiving and consuming the JSON response body.
52
+ `requestTimeoutMs` defaults to `30_000` and must be a finite positive number no greater than
53
+ `300_000`. On expiry, the client throws `AutoHarnessRequestTimeoutError`, with
54
+ `code === "REQUEST_TIMEOUT"` and the configured `timeoutMs`. The client never retries requests
55
+ automatically; reuse an idempotency key where an ambiguous `POST` may safely be retried.
56
+
57
+ ## Repository listing
58
+
59
+ Repository listings are bounded pages. Pass the returned cursor to load the next page:
60
+
61
+ ```js
62
+ let page = await harness.listRepositories({ limit: 50 });
63
+ const repositories = [...page.items];
64
+ while (page.nextCursor) {
65
+ page = await harness.listRepositories({ limit: 50, cursor: page.nextCursor });
66
+ repositories.push(...page.items);
67
+ }
68
+ ```
69
+
70
+ ## Principal session drains
71
+
72
+ Cancels this principal's own queued and running sessions for one repository, then fences new
73
+ admission from that same principal until the fence is explicitly released. **Not** repository
74
+ drain or host drain — see
75
+ [Principal session drains](https://github.com/jonathanong/auto-harness/blob/main/docs/api.md#principal-session-drains)
76
+ for the full disambiguation and server-side guarantees. Use a stable idempotency key when retries
77
+ may be ambiguous, poll the durable operation, and release the fence explicitly only after
78
+ recording its terminal result.
79
+
80
+ ```js
81
+ const drain = await harness.startSessionDrain("repo-1", {
82
+ idempotencyKey: `deploy-${process.env.GITHUB_RUN_ID}`,
83
+ });
84
+
85
+ let progress = drain;
86
+ while (progress.status === "draining") {
87
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
88
+ progress = await harness.getSessionDrain("repo-1", drain.operationId);
89
+ }
90
+ const failed = progress.status !== "succeeded";
91
+ if (failed) console.error(`Drain failed: ${progress.failureCode}`);
92
+ await harness.releaseSessionDrain("repo-1", drain.operationId);
93
+ if (failed) throw new Error(`Drain failed: ${progress.failureCode}`);
94
+ ```
95
+
96
+ When create, clone, or resume loses to the fence, `AutoHarnessError` has `code === "DRAINING"`
97
+ plus the durable `operationId` and API-relative `statusUrl`; follow that operation rather than
98
+ reimplementing pagination or cancellation reconciliation.
99
+
100
+ ## Resume a session
101
+
102
+ Resume re-runs a previously assigned session. It initially prefers the source host and its stored
103
+ native command/account route, using a native CLI resume where the provider supports it. If that
104
+ route becomes unavailable or its pin expires, the control plane clears the pin and falls back to a
105
+ fresh run through the normal target/fallback chain, which may land on another host. The source
106
+ session must have been assigned at least once, must not still be queued or running, and must not
107
+ be a scheduled session — sessions with `type: "scheduled"` are rejected with `409 CONFLICT`, since
108
+ only prompt sessions support resume.
109
+
110
+ ```js
111
+ const resumed = await harness.resumeSession("session-1", { prompt: "Address the review comments" });
112
+ console.log(resumed.status);
113
+ ```
114
+
115
+ ## List sessions
116
+
117
+ Session listings are bounded pages with the same cursor shape as repository listings, plus
118
+ filters for status, repository, host, origin, sort order, concurrency identity, and schedule
119
+ provenance:
120
+
121
+ ```js
122
+ let page = await harness.listSessions({ repositoryId: "repo-1", status: "running", limit: 50 });
123
+ const sessions = [...page.items];
124
+ while (page.nextCursor) {
125
+ page = await harness.listSessions({
126
+ repositoryId: "repo-1",
127
+ status: "running",
128
+ limit: 50,
129
+ cursor: page.nextCursor,
130
+ });
131
+ sessions.push(...page.items);
132
+ }
133
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "Dependency-free client for the Auto Harness automation API",
5
5
  "repository": {
6
6
  "type": "git",
package/src/errors.js ADDED
@@ -0,0 +1,20 @@
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
+ }
package/src/index.d.ts CHANGED
@@ -2,53 +2,262 @@ export type TargetRef =
2
2
  | { commandId: string; providerId?: never }
3
3
  | { providerId: string; commandId?: never };
4
4
 
5
+ /** A provider target, by id or by `name` — normally unique, checked defensively either way. */
6
+ export type ProviderRef =
7
+ | { providerId: string; providerName?: never; commandId?: never; commandName?: never }
8
+ | { providerName: string; providerId?: never; commandId?: never; commandName?: never };
9
+
10
+ /** A command target, by id or by `name` — command names are not required to be unique. */
11
+ export type CommandRef =
12
+ | { commandId: string; commandName?: never; providerId?: never; providerName?: never }
13
+ | { commandName: string; commandId?: never; providerId?: never; providerName?: never };
14
+
15
+ /**
16
+ * Input-only target shape for `createSession()`: an id (as `TargetRef`) or a name.
17
+ * `createSession()` resolves a name to its id via `listProviders()`/`listCommands()` before
18
+ * sending the request; a name throws on no match or on more than one match sharing that name.
19
+ */
20
+ export type TargetSpec = ProviderRef | CommandRef;
21
+
22
+ /** Values accepted for a session metadata entry. */
23
+ export type SessionMetadataValue = string | number | boolean | null;
24
+
25
+ /** Whether a session was created directly or fired by a schedule. */
26
+ export type SessionType = "prompt" | "scheduled";
27
+
28
+ /** Origin that requested the session. */
29
+ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
30
+
31
+ /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
32
+ export type CreatableSessionSource = "api" | "ui" | "webhook";
33
+
5
34
  export type CreateSessionInput = {
6
35
  repositoryId: string;
7
36
  prompt: string;
8
- target: TargetRef;
9
- fallbacks?: TargetRef[];
37
+ target: TargetSpec;
38
+ fallbacks?: TargetSpec[];
10
39
  ref?: string;
11
40
  concurrencyId?: string;
12
41
  queueTtlSeconds?: number;
13
- timeout?: number;
42
+ timeout: number;
14
43
  priority?: number;
15
44
  requiredLabels?: string[];
16
- metadata?: Record<string, unknown>;
45
+ metadata?: Record<string, SessionMetadataValue>;
46
+ /** Defaults to `"api"`; `"ui"`/`"webhook"` pass through, anything else becomes `"api"`. */
47
+ source?: CreatableSessionSource;
17
48
  };
18
49
 
19
- export type Session = CreateSessionInput & {
50
+ export type Session = {
20
51
  id: string;
52
+ repositoryId: string;
53
+ prompt: string;
54
+ target: TargetRef;
55
+ fallbacks?: TargetRef[];
56
+ ref?: string;
57
+ concurrencyId?: string;
58
+ queueTtlSeconds?: number;
59
+ timeout?: number;
60
+ priority?: number;
61
+ requiredLabels?: string[];
62
+ metadata?: Record<string, SessionMetadataValue>;
21
63
  status: string;
64
+ /** Absent on sessions persisted before this field existed. */
65
+ type?: SessionType;
66
+ /** Absent on sessions persisted before this field existed. */
67
+ source?: SessionSource;
22
68
  createdAt: string;
23
69
  url: string;
24
70
  created?: boolean;
25
71
  };
26
72
 
73
+ /** Body accepted by `POST /sessions/:id/resume`. */
74
+ export type ResumeSessionInput = {
75
+ prompt?: string;
76
+ concurrencyId?: string;
77
+ timeout?: number;
78
+ priority?: number;
79
+ };
80
+
81
+ /** `status` filter accepted by `GET /sessions`. */
82
+ export type SessionStatusFilter =
83
+ | "all"
84
+ | "queued"
85
+ | "running"
86
+ | "completed"
87
+ | "failed"
88
+ | "cancelled"
89
+ | "timed_out";
90
+
91
+ export type SessionListSort = "latest" | "oldest" | "priority_desc" | "priority_asc";
92
+
93
+ export type ListSessionsOptions = {
94
+ status?: SessionStatusFilter;
95
+ repositoryId?: string;
96
+ hostId?: string;
97
+ source?: SessionSource;
98
+ sort?: SessionListSort;
99
+ /** Number of sessions to return (1–100, default 50). */
100
+ limit?: number;
101
+ /** Opaque cursor returned by a previous page. */
102
+ cursor?: string;
103
+ concurrencyId?: string;
104
+ scheduleId?: string;
105
+ };
106
+
107
+ export type SessionPage = {
108
+ items: Session[];
109
+ nextCursor: string | null;
110
+ };
111
+
27
112
  export type Repository = {
28
113
  id: string;
29
114
  name: string;
30
115
  url: string;
31
116
  defaultBranch: string;
117
+ createdAt?: string;
118
+ updatedAt?: string;
119
+ sessionCount?: number;
120
+ worktreeCount?: number;
121
+ scheduleCount?: number;
32
122
  admissionState?: "active" | "paused" | "draining";
33
123
  admissionStateChangedAt?: string;
34
124
  drainRequestedAt?: string;
35
125
  drainCompletedAt?: string;
36
126
  };
37
127
 
128
+ export type ListRepositoriesOptions = {
129
+ /** Number of repositories to return (1–100, default 50). */
130
+ limit?: number;
131
+ /** Opaque cursor returned by a previous page. */
132
+ cursor?: string;
133
+ };
134
+
135
+ export type RepositoryPage = {
136
+ items: Repository[];
137
+ nextCursor: string | null;
138
+ };
139
+
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
+ export type SessionDrainStatus = "draining" | "succeeded" | "failed" | "released";
183
+
184
+ /** 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). */
185
+ export type SessionDrain = {
186
+ operationId: string;
187
+ repositoryId: string;
188
+ status: SessionDrainStatus;
189
+ /** API-relative URL for polling this same operation. */
190
+ statusUrl: string;
191
+ requestedAt: string;
192
+ updatedAt: string;
193
+ deadlineAt: string;
194
+ queuedCount: number;
195
+ runningCount: number;
196
+ cancelledCount: number;
197
+ completedAt?: string;
198
+ releasedAt?: string;
199
+ failureCode?: string;
200
+ };
201
+
202
+ /**
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.
208
+ */
38
209
  export class AutoHarnessError extends Error {
39
210
  status: number;
40
211
  code: string;
41
212
  retryAfter?: string;
42
- constructor(message: string, options: { status: number; code: string; retryAfter?: string });
213
+ /** Present when a 409 DRAINING admission response identifies its durable drain. */
214
+ operationId?: string;
215
+ /** API-relative URL for the drain that fenced this request. */
216
+ statusUrl?: string;
217
+ constructor(
218
+ message: string,
219
+ options: {
220
+ status: number;
221
+ code: string;
222
+ retryAfter?: string;
223
+ operationId?: string;
224
+ statusUrl?: string;
225
+ },
226
+ );
43
227
  }
44
228
 
229
+ export class AutoHarnessRequestTimeoutError extends Error {
230
+ code: "REQUEST_TIMEOUT";
231
+ timeoutMs: number;
232
+ constructor(timeoutMs: number);
233
+ }
234
+
235
+ export type AutoHarnessClientOptions = {
236
+ baseUrl: string;
237
+ apiKey?: string;
238
+ fetch?: typeof fetch;
239
+ /** Per-request deadline in milliseconds (default 30,000; maximum 300,000). */
240
+ requestTimeoutMs?: number;
241
+ };
242
+
45
243
  export class AutoHarnessClient {
46
- constructor(options: { baseUrl: string; apiKey?: string; fetch?: typeof fetch });
244
+ constructor(options: AutoHarnessClientOptions);
47
245
  createSession(input: CreateSessionInput): Promise<Session & { created: boolean }>;
48
246
  getSession(id: string): Promise<Session>;
49
247
  cancelSession(id: string): Promise<Session>;
50
- listRepositories(): Promise<{ items: Repository[] }>;
248
+ resumeSession(id: string, input?: ResumeSessionInput): Promise<Session & { created: boolean }>;
249
+ listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
250
+ /** Cancels this principal's own queued/running sessions for one repository and fences new admission from it — not repository or host drain. */
251
+ startSessionDrain(
252
+ repositoryId: string,
253
+ options?: { idempotencyKey?: string },
254
+ ): Promise<SessionDrain>;
255
+ getSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
256
+ releaseSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
257
+ listRepositories(options?: ListRepositoriesOptions): Promise<RepositoryPage>;
51
258
  pauseRepository(id: string): Promise<Repository>;
52
259
  drainRepository(id: string): Promise<Repository>;
53
260
  activateRepository(id: string): Promise<Repository>;
261
+ listProviders(): Promise<Provider[]>;
262
+ listCommands(): Promise<Command[]>;
54
263
  }
package/src/index.js CHANGED
@@ -1,44 +1,76 @@
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
- }
9
- }
1
+ import { AutoHarnessError, AutoHarnessRequestTimeoutError } from "./errors.js";
2
+ import { resolveCreateSessionTargets } from "./resolve-target.js";
3
+
4
+ export { AutoHarnessError, AutoHarnessRequestTimeoutError };
10
5
 
11
6
  export class AutoHarnessClient {
12
7
  constructor(options) {
13
8
  if (!options?.baseUrl) throw new TypeError("baseUrl is required");
9
+ const requestTimeoutMs =
10
+ options.requestTimeoutMs === undefined ? 30_000 : options.requestTimeoutMs;
11
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0 || requestTimeoutMs > 300_000) {
12
+ throw new TypeError(
13
+ "requestTimeoutMs must be a finite positive number no greater than 300000",
14
+ );
15
+ }
14
16
  this.baseUrl = options.baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
15
17
  this.apiKey = options.apiKey;
16
18
  this.fetch = options.fetch ?? globalThis.fetch;
17
19
  if (!this.fetch) throw new TypeError("fetch is required");
20
+ this.requestTimeoutMs = requestTimeoutMs;
18
21
  }
19
22
 
20
23
  async request(path, init = {}) {
21
24
  const headers = { accept: "application/json", ...init.headers };
22
25
  if (init.body !== undefined) headers["content-type"] = "application/json";
23
26
  if (this.apiKey) headers.authorization = `Bearer ${this.apiKey}`;
24
- const response = await this.fetch(`${this.baseUrl}/api/v1${path}`, { ...init, headers });
25
- const body = response.status === 204 ? undefined : await response.json().catch(() => undefined);
26
- if (!response.ok) {
27
- const error = body?.error;
28
- throw new AutoHarnessError(
29
- error?.message ?? `Auto Harness request failed (${response.status})`,
30
- {
31
- status: response.status,
32
- code: error?.code ?? "HTTP_ERROR",
33
- retryAfter: response.headers.get("retry-after") ?? undefined,
34
- },
35
- );
27
+ const controller = new AbortController();
28
+ const timeoutError = new AutoHarnessRequestTimeoutError(this.requestTimeoutMs);
29
+ let timeoutId;
30
+ const timeout = new Promise((_, reject) => {
31
+ timeoutId = setTimeout(() => {
32
+ controller.abort();
33
+ reject(timeoutError);
34
+ }, this.requestTimeoutMs);
35
+ });
36
+ const request = (async () => {
37
+ const response = await this.fetch(`${this.baseUrl}/api/v1${path}`, {
38
+ ...init,
39
+ headers,
40
+ signal: controller.signal,
41
+ });
42
+ const body =
43
+ response.status === 204
44
+ ? undefined
45
+ : await response.json().catch((error) => {
46
+ if (controller.signal.aborted) throw error;
47
+ return undefined;
48
+ });
49
+ if (!response.ok) {
50
+ const error = body?.error;
51
+ throw new AutoHarnessError(
52
+ error?.message ?? `Auto Harness request failed (${response.status})`,
53
+ {
54
+ status: response.status,
55
+ code: error?.code ?? "HTTP_ERROR",
56
+ retryAfter: response.headers.get("retry-after") ?? undefined,
57
+ operationId: error?.operationId,
58
+ statusUrl: error?.statusUrl,
59
+ },
60
+ );
61
+ }
62
+ return body;
63
+ })();
64
+ try {
65
+ return await Promise.race([request, timeout]);
66
+ } finally {
67
+ clearTimeout(timeoutId);
36
68
  }
37
- return body;
38
69
  }
39
70
 
40
- createSession(input) {
41
- return this.request("/sessions", { method: "POST", body: JSON.stringify(input) });
71
+ async createSession(input) {
72
+ const body = await resolveCreateSessionTargets(this, input);
73
+ return this.request("/sessions", { method: "POST", body: JSON.stringify(body) });
42
74
  }
43
75
 
44
76
  getSession(id) {
@@ -49,8 +81,63 @@ export class AutoHarnessClient {
49
81
  return this.request(`/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" });
50
82
  }
51
83
 
52
- listRepositories() {
53
- return this.request("/repositories");
84
+ /** Resume a previously assigned session on its pinned host, native CLI resume where supported. */
85
+ resumeSession(id, input) {
86
+ return this.request(`/sessions/${encodeURIComponent(id)}/resume`, {
87
+ method: "POST",
88
+ ...(input === undefined ? {} : { body: JSON.stringify(input) }),
89
+ });
90
+ }
91
+
92
+ listSessions(options = {}) {
93
+ const query = new URLSearchParams();
94
+ if (options.status !== undefined) query.set("status", options.status);
95
+ if (options.repositoryId !== undefined) query.set("repositoryId", options.repositoryId);
96
+ if (options.hostId !== undefined) query.set("hostId", options.hostId);
97
+ if (options.source !== undefined) query.set("source", options.source);
98
+ if (options.sort !== undefined) query.set("sort", options.sort);
99
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
100
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
101
+ if (options.concurrencyId !== undefined) query.set("concurrencyId", options.concurrencyId);
102
+ if (options.scheduleId !== undefined) query.set("scheduleId", options.scheduleId);
103
+ const suffix = query.toString();
104
+ return this.request(suffix ? `/sessions?${suffix}` : "/sessions");
105
+ }
106
+
107
+ /**
108
+ * Atomically fence this authenticated principal's session admission for one repository and
109
+ * begin cancelling its existing work. Reuse an idempotency key after an ambiguous retry.
110
+ */
111
+ startSessionDrain(repositoryId, options = {}) {
112
+ return this.request(`/repositories/${encodeURIComponent(repositoryId)}/session-drains`, {
113
+ method: "POST",
114
+ ...(options.idempotencyKey === undefined
115
+ ? {}
116
+ : { headers: { "idempotency-key": options.idempotencyKey } }),
117
+ });
118
+ }
119
+
120
+ /** Get bounded durable progress or terminal proof for one principal session drain. */
121
+ getSessionDrain(repositoryId, operationId) {
122
+ return this.request(
123
+ `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}`,
124
+ );
125
+ }
126
+
127
+ /** Explicitly reopen admission after a succeeded or failed principal session drain. */
128
+ releaseSessionDrain(repositoryId, operationId) {
129
+ return this.request(
130
+ `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}/release`,
131
+ { method: "POST" },
132
+ );
133
+ }
134
+
135
+ listRepositories(options = {}) {
136
+ const query = new URLSearchParams();
137
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
138
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
139
+ const suffix = query.toString();
140
+ return this.request(suffix ? `/repositories?${suffix}` : "/repositories");
54
141
  }
55
142
 
56
143
  pauseRepository(id) {
@@ -68,4 +155,14 @@ export class AutoHarnessClient {
68
155
  repositoryOperation(id, operation) {
69
156
  return this.request(`/repositories/${encodeURIComponent(id)}/${operation}`, { method: "POST" });
70
157
  }
158
+
159
+ async listProviders() {
160
+ const { items } = await this.request("/providers");
161
+ return items;
162
+ }
163
+
164
+ async listCommands() {
165
+ const { items } = await this.request("/commands");
166
+ return items;
167
+ }
71
168
  }
@@ -0,0 +1,54 @@
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
+ }
19
+
20
+ async function resolveRef(ref, providers, commands) {
21
+ if (ref == null || typeof ref !== "object") return ref;
22
+ // Checked by value, not `in`: a name ref built by conditional spreading can carry an
23
+ // explicit `providerId: undefined`, which `in` would treat as already resolved.
24
+ if (ref.providerId !== undefined || ref.commandId !== undefined) return ref;
25
+ if (ref.providerName !== undefined) {
26
+ return resolveByName(ref.providerName, providers, "provider", "providerId");
27
+ }
28
+ if (ref.commandName !== undefined) {
29
+ return resolveByName(ref.commandName, commands, "command", "commandId");
30
+ }
31
+ return ref;
32
+ }
33
+
34
+ /**
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.
41
+ */
42
+ export async function resolveCreateSessionTargets(client, input) {
43
+ let providersPromise;
44
+ let commandsPromise;
45
+ const providers = () => (providersPromise ??= client.listProviders());
46
+ const commands = () => (commandsPromise ??= client.listCommands());
47
+
48
+ const target = await resolveRef(input.target, providers, commands);
49
+ if (input.fallbacks === undefined) return { ...input, target };
50
+ const fallbacks = await Promise.all(
51
+ input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
52
+ );
53
+ return { ...input, target, fallbacks };
54
+ }