auto-harness-client 0.6.0 → 0.8.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.
Files changed (59) hide show
  1. package/README.md +467 -0
  2. package/package.json +5 -1
  3. package/src/cli/admin-login.js +71 -0
  4. package/src/cli/allowlist.js +34 -0
  5. package/src/cli/args.js +57 -0
  6. package/src/cli/cli-errors.js +16 -0
  7. package/src/cli/commands/api.js +91 -0
  8. package/src/cli/commands/attach-repository.js +117 -0
  9. package/src/cli/commands/dependency-conflict.js +15 -0
  10. package/src/cli/commands/detach-repository.js +109 -0
  11. package/src/cli/commands/doctor.js +164 -0
  12. package/src/cli/commands/host-drain.js +22 -0
  13. package/src/cli/commands/host-inventory-get.js +40 -0
  14. package/src/cli/commands/host-inventory-set.js +66 -0
  15. package/src/cli/commands/host-inventory.js +15 -0
  16. package/src/cli/commands/host-list.js +87 -0
  17. package/src/cli/commands/host-post-action.js +38 -0
  18. package/src/cli/commands/host-repo-add.js +81 -0
  19. package/src/cli/commands/host-repo-rm.js +51 -0
  20. package/src/cli/commands/host-repo.js +16 -0
  21. package/src/cli/commands/host-resume.js +17 -0
  22. package/src/cli/commands/host-smoke-format.js +30 -0
  23. package/src/cli/commands/host-smoke-poll.js +77 -0
  24. package/src/cli/commands/host-smoke-provider.js +172 -0
  25. package/src/cli/commands/host-smoke-repository.js +49 -0
  26. package/src/cli/commands/host-smoke-session-attempt.js +104 -0
  27. package/src/cli/commands/host-smoke-teardown.js +164 -0
  28. package/src/cli/commands/host-smoke.js +151 -0
  29. package/src/cli/commands/host.js +31 -0
  30. package/src/cli/commands/parse-worktree-flag.js +30 -0
  31. package/src/cli/commands/repo-add.js +38 -0
  32. package/src/cli/commands/repo-list.js +84 -0
  33. package/src/cli/commands/repo-rm.js +83 -0
  34. package/src/cli/commands/repo.js +18 -0
  35. package/src/cli/commands/service-account-create.js +114 -0
  36. package/src/cli/commands/service-account-list.js +84 -0
  37. package/src/cli/commands/service-account-rm.js +48 -0
  38. package/src/cli/commands/service-account.js +19 -0
  39. package/src/cli/commands/session-cancel.js +28 -0
  40. package/src/cli/commands/session-create.js +117 -0
  41. package/src/cli/commands/session-get.js +26 -0
  42. package/src/cli/commands/session-logs.js +65 -0
  43. package/src/cli/commands/session-target.js +29 -0
  44. package/src/cli/commands/session.js +22 -0
  45. package/src/cli/commands/whoami.js +22 -0
  46. package/src/cli/config.js +96 -0
  47. package/src/cli/index.js +21 -0
  48. package/src/cli/main.js +71 -0
  49. package/src/cli/path-segment.js +20 -0
  50. package/src/cli/read-stdin.js +9 -0
  51. package/src/cli/report-error.js +34 -0
  52. package/src/cli/service-account-format.js +24 -0
  53. package/src/cli/session-format.js +15 -0
  54. package/src/cli/usage.js +83 -0
  55. package/src/cli/wait-for-session.js +46 -0
  56. package/src/errors.js +1 -0
  57. package/src/index.d.ts +64 -4
  58. package/src/index.js +76 -30
  59. package/src/resolve-target.js +29 -13
package/src/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ /* eslint-disable max-lines -- one small client class covering every REST method is the published surface. */
1
2
  import {
2
3
  AutoHarnessDrainWaitTimeoutError,
3
4
  AutoHarnessError,
@@ -5,7 +6,7 @@ import {
5
6
  } from "./errors.js";
6
7
  import { assertSecureTransport } from "./loopback.js";
7
8
  import { resolveRepositoryId } from "./resolve-repository.js";
8
- import { resolveCreateSessionTargets } from "./resolve-target.js";
9
+ import { resolveCreateSessionTargets, resolveTargetSpecs } from "./resolve-target.js";
9
10
 
10
11
  export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
11
12
 
@@ -64,6 +65,7 @@ export class AutoHarnessClient {
64
65
  retryAfter: response.headers.get("retry-after") ?? undefined,
65
66
  operationId: error?.operationId,
66
67
  statusUrl: error?.statusUrl,
68
+ details: error,
67
69
  },
68
70
  );
69
71
  }
@@ -85,15 +87,42 @@ export class AutoHarnessClient {
85
87
  return this.request(`/sessions/${encodeURIComponent(id)}`);
86
88
  }
87
89
 
90
+ createChildSession(parentId, input) {
91
+ return this.request(`/sessions/${encodeURIComponent(parentId)}/children`, {
92
+ method: "POST",
93
+ body: JSON.stringify(input),
94
+ });
95
+ }
96
+
97
+ listChildSessions(parentId, options = {}) {
98
+ const query = new URLSearchParams();
99
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
100
+ if (options.cursor !== undefined) query.set("cursor", options.cursor);
101
+ const suffix = query.toString();
102
+ return this.request(
103
+ suffix
104
+ ? `/sessions/${encodeURIComponent(parentId)}/children?${suffix}`
105
+ : `/sessions/${encodeURIComponent(parentId)}/children`,
106
+ );
107
+ }
108
+
88
109
  cancelSession(id) {
89
110
  return this.request(`/sessions/${encodeURIComponent(id)}/cancel`, { method: "POST" });
90
111
  }
91
112
 
92
- /** Resume a previously assigned session on its pinned host, native CLI resume where supported. */
93
- resumeSession(id, input) {
113
+ /**
114
+ * Resume a previously assigned session on its pinned host, native CLI resume where supported.
115
+ * An optional `target`/`fallbacks` override rebinds the resume to a different Command/Provider
116
+ * — a repoint applying on the next resume, instead of only to new dispatches — and its
117
+ * `providerName`/`commandName` sugar is resolved the same way `createSession()` resolves it.
118
+ * Resolution only runs when `target` is present, so an id-only or bodyless resume makes no
119
+ * extra `listCommands()`/`listProviders()` requests.
120
+ */
121
+ async resumeSession(id, input) {
122
+ const body = input?.target === undefined ? input : await resolveTargetSpecs(this, input);
94
123
  return this.request(`/sessions/${encodeURIComponent(id)}/resume`, {
95
124
  method: "POST",
96
- ...(input === undefined ? {} : { body: JSON.stringify(input) }),
125
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
97
126
  });
98
127
  }
99
128
 
@@ -161,37 +190,40 @@ export class AutoHarnessClient {
161
190
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
162
191
  throw new TypeError("timeoutMs must be a finite positive number");
163
192
  const deadline = Date.now() + timeoutMs;
164
- const clampedClient = () =>
165
- 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
193
+ const clampedClient = () => {
194
+ const remainingMs = deadline - Date.now();
195
+ const requestTimeoutMs = Math.max(1, Math.ceil(Math.min(this.requestTimeoutMs, remainingMs)));
196
+ return {
197
+ client: new AutoHarnessClient({
198
+ baseUrl: this.baseUrl,
199
+ apiKey: this.apiKey,
200
+ allowInsecureHttp: this.allowInsecureHttp,
201
+ fetch: this.fetch,
202
+ requestTimeoutMs,
203
+ }),
204
+ clampedToWaitBudget: remainingMs <= this.requestTimeoutMs,
205
+ };
206
+ };
207
+ const rejectAtDeadline = (error, idForError, clampedToWaitBudget) =>
208
+ error instanceof AutoHarnessRequestTimeoutError &&
209
+ (clampedToWaitBudget || Date.now() >= deadline)
177
210
  ? new AutoHarnessDrainWaitTimeoutError(idForError, operationId, timeoutMs)
178
211
  : error;
179
212
 
213
+ const repositoryLookup = clampedClient();
180
214
  const id = await resolveRepositoryId(
181
- { listRepositories: (listOptions) => clampedClient().listRepositories(listOptions) },
215
+ { listRepositories: (opts) => repositoryLookup.client.listRepositories(opts) },
182
216
  repositoryId,
183
217
  ).catch((error) => {
184
- throw rejectAtDeadline(error, repositoryId);
218
+ throw rejectAtDeadline(error, repositoryId, repositoryLookup.clampedToWaitBudget);
185
219
  });
186
220
  for (;;) {
187
- if (deadline - Date.now() <= 0) {
221
+ if (deadline - Date.now() <= 0)
188
222
  throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
189
- }
190
- const sessionDrain = await clampedClient()
191
- .getSessionDrain(id, operationId)
192
- .catch((error) => {
193
- throw rejectAtDeadline(error, id);
194
- });
223
+ const { client, clampedToWaitBudget } = clampedClient();
224
+ const sessionDrain = await client.getSessionDrain(id, operationId).catch((error) => {
225
+ throw rejectAtDeadline(error, id, clampedToWaitBudget);
226
+ });
195
227
  if (sessionDrain.status !== "draining") return sessionDrain;
196
228
  const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
197
229
  if (delayMs <= 0) throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
@@ -224,12 +256,26 @@ export class AutoHarnessClient {
224
256
  }
225
257
 
226
258
  async listProviders() {
227
- const { items } = await this.request("/providers");
228
- return items;
259
+ return this.listCatalog("/providers");
229
260
  }
230
261
 
231
262
  async listCommands() {
232
- const { items } = await this.request("/commands");
233
- return items;
263
+ return this.listCatalog("/commands");
264
+ }
265
+
266
+ async listCatalog(path) {
267
+ const items = [];
268
+ const seen = new Set();
269
+ let suffix = "?limit=100";
270
+ for (let page = 0; page < 20; page += 1) {
271
+ const body = await this.request(`${path}${suffix}`);
272
+ items.push(...(body.items ?? []));
273
+ const cursor = body.nextCursor ?? null;
274
+ if (!cursor) return items;
275
+ if (seen.has(cursor)) throw new Error(`repeated pagination cursor for ${path}`);
276
+ seen.add(cursor);
277
+ suffix = `?limit=100&cursor=${encodeURIComponent(cursor)}`;
278
+ }
279
+ throw new Error(`pagination exceeded 20 pages for ${path}`);
234
280
  }
235
281
  }
@@ -16,31 +16,47 @@ async function resolveRef(ref, providers, commands) {
16
16
  }
17
17
 
18
18
  /**
19
- * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, 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.
19
+ * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks` to their ids
20
+ * via `client.listProviders()`/`listCommands()`, each called at most once regardless of how many
21
+ * refs need it. Id-shaped refs pass through untouched, so an all-id call makes no extra requests.
22
+ * Names are resolved defensively against more than one match: create/update checks are
23
+ * read-then-write races rather than atomic constraints, and legacy catalog rows are not rewritten.
24
+ * Shared by session create and resume — resume has no repository to resolve, so it calls this
25
+ * directly instead of `resolveCreateSessionTargets`.
25
26
  */
26
- export async function resolveCreateSessionTargets(client, input) {
27
+ export async function resolveTargetSpecs(client, input) {
27
28
  let providersPromise;
28
29
  let commandsPromise;
29
30
  const providers = () => (providersPromise ??= client.listProviders());
30
31
  const commands = () => (commandsPromise ??= client.listCommands());
31
32
 
32
33
  const target = await resolveRef(input.target, providers, commands);
34
+ const resolved = { ...input, target };
35
+ if (input.fallbacks === undefined) return resolved;
36
+ const fallbacks = await Promise.all(
37
+ input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
38
+ );
39
+ return { ...resolved, fallbacks };
40
+ }
41
+
42
+ /**
43
+ * Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
44
+ * `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
45
+ * `listRepositories()`.
46
+ */
47
+ export async function resolveCreateSessionTargets(client, input) {
48
+ if (Object.hasOwn(input, "setupScript")) {
49
+ throw new TypeError("setupScript is not accepted; use setupProfileId");
50
+ }
51
+ const withTargets = await resolveTargetSpecs(client, input);
52
+ if (input.repositoryId === null) return withTargets;
33
53
  const repositoryId = await resolveRepositoryId(
34
54
  client,
35
55
  input.repositoryId !== undefined
36
56
  ? input.repositoryId
37
57
  : { repositoryName: input.repositoryName },
38
58
  );
39
- const resolved = { ...input, repositoryId, target };
59
+ const resolved = { ...withTargets, repositoryId };
40
60
  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 };
61
+ return resolved;
46
62
  }