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