auto-harness-client 0.1.0 → 0.3.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,96 @@ 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
+ ## Request deadlines
26
+
27
+ Every request has a deadline that includes receiving and consuming the JSON response body.
28
+ `requestTimeoutMs` defaults to `30_000` and must be a finite positive number no greater than
29
+ `300_000`. On expiry, the client throws `AutoHarnessRequestTimeoutError`, with
30
+ `code === "REQUEST_TIMEOUT"` and the configured `timeoutMs`. The client never retries requests
31
+ automatically; reuse an idempotency key where an ambiguous `POST` may safely be retried.
32
+
33
+ ## Repository listing
34
+
35
+ Repository listings are bounded pages. Pass the returned cursor to load the next page:
36
+
37
+ ```js
38
+ let page = await harness.listRepositories({ limit: 50 });
39
+ const repositories = [...page.items];
40
+ while (page.nextCursor) {
41
+ page = await harness.listRepositories({ limit: 50, cursor: page.nextCursor });
42
+ repositories.push(...page.items);
43
+ }
44
+ ```
45
+
46
+ ## Principal session drains
47
+
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.
52
+
53
+ ```js
54
+ const drain = await harness.startSessionDrain("repo-1", {
55
+ idempotencyKey: `deploy-${process.env.GITHUB_RUN_ID}`,
56
+ });
57
+
58
+ let progress = drain;
59
+ while (progress.status === "draining") {
60
+ await new Promise((resolve) => setTimeout(resolve, 5_000));
61
+ progress = await harness.getSessionDrain("repo-1", drain.operationId);
62
+ }
63
+ if (progress.status !== "succeeded") throw new Error(`Drain failed: ${progress.failureCode}`);
64
+ await harness.releaseSessionDrain("repo-1", drain.operationId);
65
+ ```
66
+
67
+ When create, clone, or resume loses to the fence, `AutoHarnessError` has `code === "DRAINING"`
68
+ plus the durable `operationId` and API-relative `statusUrl`; follow that operation rather than
69
+ reimplementing pagination or cancellation reconciliation.
70
+
71
+ ## Resume a session
72
+
73
+ Resume re-runs a previously assigned session. It initially prefers the source host and its stored
74
+ native command/account route, using a native CLI resume where the provider supports it. If that
75
+ route becomes unavailable or its pin expires, the control plane clears the pin and falls back to a
76
+ fresh run through the normal target/fallback chain, which may land on another host. The source
77
+ session must have been assigned at least once, must not still be queued or running, and must not
78
+ be a scheduled session — sessions with `type: "scheduled"` are rejected with `409 CONFLICT`, since
79
+ only prompt sessions support resume.
80
+
81
+ ```js
82
+ const resumed = await harness.resumeSession("session-1", { prompt: "Address the review comments" });
83
+ console.log(resumed.status);
84
+ ```
85
+
86
+ ## List sessions
87
+
88
+ Session listings are bounded pages with the same cursor shape as repository listings, plus
89
+ filters for status, repository, host, origin, sort order, concurrency identity, and schedule
90
+ provenance:
91
+
92
+ ```js
93
+ let page = await harness.listSessions({ repositoryId: "repo-1", status: "running", limit: 50 });
94
+ const sessions = [...page.items];
95
+ while (page.nextCursor) {
96
+ page = await harness.listSessions({
97
+ repositoryId: "repo-1",
98
+ status: "running",
99
+ limit: 50,
100
+ cursor: page.nextCursor,
101
+ });
102
+ sessions.push(...page.items);
103
+ }
104
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-harness-client",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Dependency-free client for the Auto Harness automation API",
5
5
  "repository": {
6
6
  "type": "git",
package/src/index.d.ts CHANGED
@@ -2,6 +2,18 @@ export type TargetRef =
2
2
  | { commandId: string; providerId?: never }
3
3
  | { providerId: string; commandId?: never };
4
4
 
5
+ /** Values accepted for a session metadata entry. */
6
+ export type SessionMetadataValue = string | number | boolean | null;
7
+
8
+ /** Whether a session was created directly or fired by a schedule. */
9
+ export type SessionType = "prompt" | "scheduled";
10
+
11
+ /** Origin that requested the session. */
12
+ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
13
+
14
+ /** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
15
+ export type CreatableSessionSource = "api" | "ui" | "webhook";
16
+
5
17
  export type CreateSessionInput = {
6
18
  repositoryId: string;
7
19
  prompt: string;
@@ -10,44 +22,172 @@ export type CreateSessionInput = {
10
22
  ref?: string;
11
23
  concurrencyId?: string;
12
24
  queueTtlSeconds?: number;
13
- timeout?: number;
25
+ timeout: number;
14
26
  priority?: number;
15
27
  requiredLabels?: string[];
16
- metadata?: Record<string, unknown>;
28
+ metadata?: Record<string, SessionMetadataValue>;
29
+ /** Defaults to `"api"`; `"ui"`/`"webhook"` pass through, anything else becomes `"api"`. */
30
+ source?: CreatableSessionSource;
17
31
  };
18
32
 
19
- export type Session = CreateSessionInput & {
33
+ export type Session = {
20
34
  id: string;
35
+ repositoryId: string;
36
+ prompt: string;
37
+ target: TargetRef;
38
+ fallbacks?: TargetRef[];
39
+ ref?: string;
40
+ concurrencyId?: string;
41
+ queueTtlSeconds?: number;
42
+ timeout?: number;
43
+ priority?: number;
44
+ requiredLabels?: string[];
45
+ metadata?: Record<string, SessionMetadataValue>;
21
46
  status: string;
47
+ /** Absent on sessions persisted before this field existed. */
48
+ type?: SessionType;
49
+ /** Absent on sessions persisted before this field existed. */
50
+ source?: SessionSource;
22
51
  createdAt: string;
23
52
  url: string;
24
53
  created?: boolean;
25
54
  };
26
55
 
56
+ /** Body accepted by `POST /sessions/:id/resume`. */
57
+ export type ResumeSessionInput = {
58
+ prompt?: string;
59
+ concurrencyId?: string;
60
+ timeout?: number;
61
+ priority?: number;
62
+ };
63
+
64
+ /** `status` filter accepted by `GET /sessions`. */
65
+ export type SessionStatusFilter =
66
+ | "all"
67
+ | "queued"
68
+ | "running"
69
+ | "completed"
70
+ | "failed"
71
+ | "cancelled"
72
+ | "timed_out";
73
+
74
+ export type SessionListSort = "latest" | "oldest" | "priority_desc" | "priority_asc";
75
+
76
+ export type ListSessionsOptions = {
77
+ status?: SessionStatusFilter;
78
+ repositoryId?: string;
79
+ hostId?: string;
80
+ source?: SessionSource;
81
+ sort?: SessionListSort;
82
+ /** Number of sessions to return (1–100, default 50). */
83
+ limit?: number;
84
+ /** Opaque cursor returned by a previous page. */
85
+ cursor?: string;
86
+ concurrencyId?: string;
87
+ scheduleId?: string;
88
+ };
89
+
90
+ export type SessionPage = {
91
+ items: Session[];
92
+ nextCursor: string | null;
93
+ };
94
+
27
95
  export type Repository = {
28
96
  id: string;
29
97
  name: string;
30
98
  url: string;
31
99
  defaultBranch: string;
100
+ createdAt?: string;
101
+ updatedAt?: string;
102
+ sessionCount?: number;
103
+ worktreeCount?: number;
104
+ scheduleCount?: number;
32
105
  admissionState?: "active" | "paused" | "draining";
33
106
  admissionStateChangedAt?: string;
34
107
  drainRequestedAt?: string;
35
108
  drainCompletedAt?: string;
36
109
  };
37
110
 
111
+ export type ListRepositoriesOptions = {
112
+ /** Number of repositories to return (1–100, default 50). */
113
+ limit?: number;
114
+ /** Opaque cursor returned by a previous page. */
115
+ cursor?: string;
116
+ };
117
+
118
+ export type RepositoryPage = {
119
+ items: Repository[];
120
+ nextCursor: string | null;
121
+ };
122
+
123
+ export type SessionDrainStatus = "draining" | "succeeded" | "failed" | "released";
124
+
125
+ /** Bounded, durable progress for the authenticated principal's repository session drain. */
126
+ export type SessionDrain = {
127
+ operationId: string;
128
+ repositoryId: string;
129
+ status: SessionDrainStatus;
130
+ /** API-relative URL for polling this same operation. */
131
+ statusUrl: string;
132
+ requestedAt: string;
133
+ updatedAt: string;
134
+ deadlineAt: string;
135
+ queuedCount: number;
136
+ runningCount: number;
137
+ cancelledCount: number;
138
+ completedAt?: string;
139
+ releasedAt?: string;
140
+ failureCode?: string;
141
+ };
142
+
38
143
  export class AutoHarnessError extends Error {
39
144
  status: number;
40
145
  code: string;
41
146
  retryAfter?: string;
42
- constructor(message: string, options: { status: number; code: string; retryAfter?: string });
147
+ /** Present when a 409 DRAINING admission response identifies its durable drain. */
148
+ operationId?: string;
149
+ /** API-relative URL for the drain that fenced this request. */
150
+ statusUrl?: string;
151
+ constructor(
152
+ message: string,
153
+ options: {
154
+ status: number;
155
+ code: string;
156
+ retryAfter?: string;
157
+ operationId?: string;
158
+ statusUrl?: string;
159
+ },
160
+ );
43
161
  }
44
162
 
163
+ export class AutoHarnessRequestTimeoutError extends Error {
164
+ code: "REQUEST_TIMEOUT";
165
+ timeoutMs: number;
166
+ constructor(timeoutMs: number);
167
+ }
168
+
169
+ export type AutoHarnessClientOptions = {
170
+ baseUrl: string;
171
+ apiKey?: string;
172
+ fetch?: typeof fetch;
173
+ /** Per-request deadline in milliseconds (default 30,000; maximum 300,000). */
174
+ requestTimeoutMs?: number;
175
+ };
176
+
45
177
  export class AutoHarnessClient {
46
- constructor(options: { baseUrl: string; apiKey?: string; fetch?: typeof fetch });
178
+ constructor(options: AutoHarnessClientOptions);
47
179
  createSession(input: CreateSessionInput): Promise<Session & { created: boolean }>;
48
180
  getSession(id: string): Promise<Session>;
49
181
  cancelSession(id: string): Promise<Session>;
50
- listRepositories(): Promise<{ items: Repository[] }>;
182
+ resumeSession(id: string, input?: ResumeSessionInput): Promise<Session & { created: boolean }>;
183
+ listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
184
+ startSessionDrain(
185
+ repositoryId: string,
186
+ options?: { idempotencyKey?: string },
187
+ ): Promise<SessionDrain>;
188
+ getSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
189
+ releaseSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
190
+ listRepositories(options?: ListRepositoriesOptions): Promise<RepositoryPage>;
51
191
  pauseRepository(id: string): Promise<Repository>;
52
192
  drainRepository(id: string): Promise<Repository>;
53
193
  activateRepository(id: string): Promise<Repository>;
package/src/index.js CHANGED
@@ -5,36 +5,83 @@ export class AutoHarnessError extends Error {
5
5
  this.status = options.status;
6
6
  this.code = options.code;
7
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;
8
19
  }
9
20
  }
10
21
 
11
22
  export class AutoHarnessClient {
12
23
  constructor(options) {
13
24
  if (!options?.baseUrl) throw new TypeError("baseUrl is required");
25
+ const requestTimeoutMs =
26
+ options.requestTimeoutMs === undefined ? 30_000 : options.requestTimeoutMs;
27
+ if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0 || requestTimeoutMs > 300_000) {
28
+ throw new TypeError(
29
+ "requestTimeoutMs must be a finite positive number no greater than 300000",
30
+ );
31
+ }
14
32
  this.baseUrl = options.baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
15
33
  this.apiKey = options.apiKey;
16
34
  this.fetch = options.fetch ?? globalThis.fetch;
17
35
  if (!this.fetch) throw new TypeError("fetch is required");
36
+ this.requestTimeoutMs = requestTimeoutMs;
18
37
  }
19
38
 
20
39
  async request(path, init = {}) {
21
40
  const headers = { accept: "application/json", ...init.headers };
22
41
  if (init.body !== undefined) headers["content-type"] = "application/json";
23
42
  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
- );
43
+ const controller = new AbortController();
44
+ const timeoutError = new AutoHarnessRequestTimeoutError(this.requestTimeoutMs);
45
+ let timeoutId;
46
+ const timeout = new Promise((_, reject) => {
47
+ timeoutId = setTimeout(() => {
48
+ controller.abort();
49
+ reject(timeoutError);
50
+ }, this.requestTimeoutMs);
51
+ });
52
+ const request = (async () => {
53
+ const response = await this.fetch(`${this.baseUrl}/api/v1${path}`, {
54
+ ...init,
55
+ headers,
56
+ signal: controller.signal,
57
+ });
58
+ const body =
59
+ response.status === 204
60
+ ? undefined
61
+ : await response.json().catch((error) => {
62
+ if (controller.signal.aborted) throw error;
63
+ return undefined;
64
+ });
65
+ if (!response.ok) {
66
+ const error = body?.error;
67
+ throw new AutoHarnessError(
68
+ error?.message ?? `Auto Harness request failed (${response.status})`,
69
+ {
70
+ status: response.status,
71
+ code: error?.code ?? "HTTP_ERROR",
72
+ retryAfter: response.headers.get("retry-after") ?? undefined,
73
+ operationId: error?.operationId,
74
+ statusUrl: error?.statusUrl,
75
+ },
76
+ );
77
+ }
78
+ return body;
79
+ })();
80
+ try {
81
+ return await Promise.race([request, timeout]);
82
+ } finally {
83
+ clearTimeout(timeoutId);
36
84
  }
37
- return body;
38
85
  }
39
86
 
40
87
  createSession(input) {
@@ -49,8 +96,63 @@ export class AutoHarnessClient {
49
96
  return this.request(`/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" });
50
97
  }
51
98
 
52
- listRepositories() {
53
- return this.request("/repositories");
99
+ /** Resume a previously assigned session on its pinned host, native CLI resume where supported. */
100
+ resumeSession(id, input) {
101
+ return this.request(`/sessions/${encodeURIComponent(id)}/resume`, {
102
+ method: "POST",
103
+ ...(input === undefined ? {} : { body: JSON.stringify(input) }),
104
+ });
105
+ }
106
+
107
+ listSessions(options = {}) {
108
+ const query = new URLSearchParams();
109
+ if (options.status !== undefined) query.set("status", options.status);
110
+ if (options.repositoryId !== undefined) query.set("repositoryId", options.repositoryId);
111
+ if (options.hostId !== undefined) query.set("hostId", options.hostId);
112
+ if (options.source !== undefined) query.set("source", options.source);
113
+ if (options.sort !== undefined) query.set("sort", options.sort);
114
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
115
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
116
+ if (options.concurrencyId !== undefined) query.set("concurrencyId", options.concurrencyId);
117
+ if (options.scheduleId !== undefined) query.set("scheduleId", options.scheduleId);
118
+ const suffix = query.toString();
119
+ return this.request(suffix ? `/sessions?${suffix}` : "/sessions");
120
+ }
121
+
122
+ /**
123
+ * Atomically fence this authenticated principal's session admission for one repository and
124
+ * begin cancelling its existing work. Reuse an idempotency key after an ambiguous retry.
125
+ */
126
+ startSessionDrain(repositoryId, options = {}) {
127
+ return this.request(`/repositories/${encodeURIComponent(repositoryId)}/session-drains`, {
128
+ method: "POST",
129
+ ...(options.idempotencyKey === undefined
130
+ ? {}
131
+ : { headers: { "idempotency-key": options.idempotencyKey } }),
132
+ });
133
+ }
134
+
135
+ /** Get bounded durable progress or terminal proof for one principal session drain. */
136
+ getSessionDrain(repositoryId, operationId) {
137
+ return this.request(
138
+ `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}`,
139
+ );
140
+ }
141
+
142
+ /** Explicitly reopen admission after a succeeded or failed principal session drain. */
143
+ releaseSessionDrain(repositoryId, operationId) {
144
+ return this.request(
145
+ `/repositories/${encodeURIComponent(repositoryId)}/session-drains/${encodeURIComponent(operationId)}/release`,
146
+ { method: "POST" },
147
+ );
148
+ }
149
+
150
+ listRepositories(options = {}) {
151
+ const query = new URLSearchParams();
152
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
153
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
154
+ const suffix = query.toString();
155
+ return this.request(suffix ? `/repositories?${suffix}` : "/repositories");
54
156
  }
55
157
 
56
158
  pauseRepository(id) {