auto-harness-client 0.4.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 +71 -4
- 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 +11 -0
- package/src/index.d.ts +58 -53
- package/src/index.js +75 -8
- 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 +20 -28
package/README.md
CHANGED
|
@@ -22,6 +22,12 @@ const session = await harness.createSession({
|
|
|
22
22
|
console.log(session.url);
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
`baseUrl` must be `https` whenever `apiKey` is set — the constructor throws otherwise. Pass
|
|
26
|
+
`allowInsecureHttp: true` to opt out, but only for a genuine loopback `baseUrl` (`127.0.0.0/8`,
|
|
27
|
+
`::1`, or `localhost`) — the constructor verifies this itself and throws for any other `http:`
|
|
28
|
+
`baseUrl` even when the flag is set. A private-network address (RFC1918) still crosses real network
|
|
29
|
+
hardware and does not qualify.
|
|
30
|
+
|
|
25
31
|
## Target by provider or command name
|
|
26
32
|
|
|
27
33
|
`target` and `fallbacks` accept a `providerId`/`commandId` as before, or a human-readable
|
|
@@ -39,13 +45,34 @@ const session = await harness.createSession({
|
|
|
39
45
|
});
|
|
40
46
|
```
|
|
41
47
|
|
|
42
|
-
Provider names are
|
|
43
|
-
read-then-write
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
Provider and Command names are server-enforced unique slugs within their respective catalogs on
|
|
49
|
+
create/update, but those checks are read-then-write races rather than atomic constraints, and
|
|
50
|
+
legacy catalog rows are not rewritten.
|
|
51
|
+
Name resolution therefore still checks for more than one match rather than trusting uniqueness.
|
|
52
|
+
Either way, an unresolvable or ambiguous name throws `AutoHarnessError`
|
|
46
53
|
(`code === "UNKNOWN_PROVIDER_NAME"`, `"UNKNOWN_COMMAND_NAME"`, `"AMBIGUOUS_PROVIDER_NAME"`, or
|
|
47
54
|
`"AMBIGUOUS_COMMAND_NAME"`); the ambiguous-name message never includes the matched ids.
|
|
48
55
|
|
|
56
|
+
## Repository by name
|
|
57
|
+
|
|
58
|
+
`createSession()` and the principal session drain methods (`startSessionDrain()`,
|
|
59
|
+
`getSessionDrain()`, `releaseSessionDrain()`, `waitForSessionDrain()`) accept a `RepositoryRef` —
|
|
60
|
+
`{ repositoryId }` as before, or `{ repositoryName }` — wherever they take a `repositoryId`
|
|
61
|
+
parameter. `listSessions()`'s `repositoryId` filter and the repository administration methods
|
|
62
|
+
(`pauseRepository()`, `drainRepository()`, `activateRepository()`) remain id-only. Repositories are
|
|
63
|
+
not exposed as a single unpaginated catalog call, so resolving by name pages through
|
|
64
|
+
`listRepositories()` in full before matching. An unresolvable or ambiguous name throws
|
|
65
|
+
`AutoHarnessError` (`code === "UNKNOWN_REPOSITORY_NAME"` or `"AMBIGUOUS_REPOSITORY_NAME"`).
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
const session = await harness.createSession({
|
|
69
|
+
repositoryName: "voucha/filaments",
|
|
70
|
+
prompt: "Review the latest changes",
|
|
71
|
+
target: { providerId: "codex" },
|
|
72
|
+
timeout: 1_800,
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
49
76
|
## Request deadlines
|
|
50
77
|
|
|
51
78
|
Every request has a deadline that includes receiving and consuming the JSON response body.
|
|
@@ -97,6 +124,27 @@ When create, clone, or resume loses to the fence, `AutoHarnessError` has `code =
|
|
|
97
124
|
plus the durable `operationId` and API-relative `statusUrl`; follow that operation rather than
|
|
98
125
|
reimplementing pagination or cancellation reconciliation.
|
|
99
126
|
|
|
127
|
+
`waitForSessionDrain(repositoryId, operationId, { pollIntervalMs, timeoutMs })` replaces the manual
|
|
128
|
+
poll loop above: it resolves `repositoryId`, then polls `getSessionDrain()` until a terminal status,
|
|
129
|
+
clamping every request — including each page fetched to resolve a `repositoryName` — to the time
|
|
130
|
+
remaining before `timeoutMs`. It resolves with the terminal `SessionDrain` for any status, including
|
|
131
|
+
`"failed"` and `"released"` — callers classify success themselves — and rejects with
|
|
132
|
+
`AutoHarnessDrainWaitTimeoutError` (`code === "DRAIN_WAIT_TIMEOUT"`) when the overall `timeoutMs`
|
|
133
|
+
budget elapses — including when a clamped request is the thing that times out at that same instant
|
|
134
|
+
— or with `AutoHarnessRequestTimeoutError` (`code === "REQUEST_TIMEOUT"`) if an individual request
|
|
135
|
+
times out while budget still remains.
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
const progress = await harness.waitForSessionDrain("repo-1", drain.operationId, {
|
|
139
|
+
pollIntervalMs: 5_000,
|
|
140
|
+
timeoutMs: 300_000,
|
|
141
|
+
});
|
|
142
|
+
const failed = progress.status !== "succeeded";
|
|
143
|
+
if (failed) console.error(`Drain failed: ${progress.failureCode}`);
|
|
144
|
+
await harness.releaseSessionDrain("repo-1", drain.operationId);
|
|
145
|
+
if (failed) throw new Error(`Drain failed: ${progress.failureCode}`);
|
|
146
|
+
```
|
|
147
|
+
|
|
100
148
|
## Resume a session
|
|
101
149
|
|
|
102
150
|
Resume re-runs a previously assigned session. It initially prefers the source host and its stored
|
|
@@ -131,3 +179,22 @@ while (page.nextCursor) {
|
|
|
131
179
|
sessions.push(...page.items);
|
|
132
180
|
}
|
|
133
181
|
```
|
|
182
|
+
|
|
183
|
+
## `auto-harness-client/actions`
|
|
184
|
+
|
|
185
|
+
Dependency-free helpers for authoring a GitHub Action that dispatches Auto Harness sessions from
|
|
186
|
+
`INPUT_*` env vars: `requiredEnvironmentValue`, `parseInteger`, `parseRequiredLabels`,
|
|
187
|
+
`parseConcurrencyId`, `parseMetadata`, `parseHarnessApiOrigin`, `parseHarnessTarget`,
|
|
188
|
+
`parseHarnessFallbacks`, `TARGET_SPEC_KEYS`, `HarnessDispatchError`, and `writeOutputs` (for
|
|
189
|
+
`GITHUB_OUTPUT`/`GITHUB_STEP_SUMMARY`/`::notice` reporting). These parse the same env-var contract
|
|
190
|
+
that `auto-harness`'s own
|
|
191
|
+
[`actions/dispatch`](https://github.com/jonathanong/auto-harness/blob/main/actions/dispatch) action
|
|
192
|
+
uses, so a consuming
|
|
193
|
+
workflow's inline script and a bundled composite action stay in sync with the same validation.
|
|
194
|
+
|
|
195
|
+
```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
CHANGED
|
@@ -18,3 +18,14 @@ export class AutoHarnessRequestTimeoutError extends Error {
|
|
|
18
18
|
this.timeoutMs = timeoutMs;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
export class AutoHarnessDrainWaitTimeoutError extends Error {
|
|
23
|
+
constructor(repositoryId, operationId, timeoutMs) {
|
|
24
|
+
super(`Auto Harness session drain wait timed out after ${timeoutMs}ms`);
|
|
25
|
+
this.name = "AutoHarnessDrainWaitTimeoutError";
|
|
26
|
+
this.code = "DRAIN_WAIT_TIMEOUT";
|
|
27
|
+
this.repositoryId = repositoryId;
|
|
28
|
+
this.operationId = operationId;
|
|
29
|
+
this.timeoutMs = timeoutMs;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { Command, Provider } from "./catalog-types.js";
|
|
2
|
+
export type { Command, Provider, ResumeRefCapture, UsageRates } from "./catalog-types.js";
|
|
3
|
+
|
|
1
4
|
export type TargetRef =
|
|
2
5
|
| { commandId: string; providerId?: never }
|
|
3
6
|
| { providerId: string; commandId?: never };
|
|
@@ -7,7 +10,7 @@ export type ProviderRef =
|
|
|
7
10
|
| { providerId: string; providerName?: never; commandId?: never; commandName?: never }
|
|
8
11
|
| { providerName: string; providerId?: never; commandId?: never; commandName?: never };
|
|
9
12
|
|
|
10
|
-
/** A command target, by id or by `name` —
|
|
13
|
+
/** A command target, by id or by `name` — checked defensively for legacy/racy duplicates. */
|
|
11
14
|
export type CommandRef =
|
|
12
15
|
| { commandId: string; commandName?: never; providerId?: never; providerName?: never }
|
|
13
16
|
| { commandName: string; commandId?: never; providerId?: never; providerName?: never };
|
|
@@ -19,6 +22,11 @@ export type CommandRef =
|
|
|
19
22
|
*/
|
|
20
23
|
export type TargetSpec = ProviderRef | CommandRef;
|
|
21
24
|
|
|
25
|
+
/** A repository target, by id or by unique `name` — checked defensively for legacy/racy duplicates. */
|
|
26
|
+
export type RepositoryRef =
|
|
27
|
+
| { repositoryId: string; repositoryName?: never }
|
|
28
|
+
| { repositoryName: string; repositoryId?: never };
|
|
29
|
+
|
|
22
30
|
/** Values accepted for a session metadata entry. */
|
|
23
31
|
export type SessionMetadataValue = string | number | boolean | null;
|
|
24
32
|
|
|
@@ -31,8 +39,7 @@ export type SessionSource = "api" | "ui" | "webhook" | "schedule";
|
|
|
31
39
|
/** `source` values `POST /sessions` honors; anything else collapses to `"api"`. */
|
|
32
40
|
export type CreatableSessionSource = "api" | "ui" | "webhook";
|
|
33
41
|
|
|
34
|
-
export type CreateSessionInput = {
|
|
35
|
-
repositoryId: string;
|
|
42
|
+
export type CreateSessionInput = RepositoryRef & {
|
|
36
43
|
prompt: string;
|
|
37
44
|
target: TargetSpec;
|
|
38
45
|
fallbacks?: TargetSpec[];
|
|
@@ -137,48 +144,6 @@ export type RepositoryPage = {
|
|
|
137
144
|
nextCursor: string | null;
|
|
138
145
|
};
|
|
139
146
|
|
|
140
|
-
/** Operator-supplied per-token vendor rates; Auto Harness never fetches vendor prices. */
|
|
141
|
-
export type UsageRates = {
|
|
142
|
-
inputTokenMicros?: string;
|
|
143
|
-
outputTokenMicros?: string;
|
|
144
|
-
cachedInputTokenMicros?: string;
|
|
145
|
-
reasoningTokenMicros?: string;
|
|
146
|
-
currency: string;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
/** Global catalog entry: an AI CLI vendor, keyed by a unique, server-enforced `name`. */
|
|
150
|
-
export type Provider = {
|
|
151
|
-
id: string;
|
|
152
|
-
/** e.g. "claude", "codex", "grok" */
|
|
153
|
-
name: string;
|
|
154
|
-
defaultCommandId: string | null;
|
|
155
|
-
createdAt: string;
|
|
156
|
-
updatedAt: string;
|
|
157
|
-
usageRates?: UsageRates;
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
/** Bounded literal-prefix policy used by the agent to extract a native resume reference. */
|
|
161
|
-
export type ResumeRefCapture = {
|
|
162
|
-
stream: "stdout" | "stderr" | "either";
|
|
163
|
-
linePrefix: string;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
/** Global catalog entry: a named command invocation. `name` is not required to be unique. */
|
|
167
|
-
export type Command = {
|
|
168
|
-
id: string;
|
|
169
|
-
/** e.g. "claude-print", "echo hello world" */
|
|
170
|
-
name: string;
|
|
171
|
-
argv: string[];
|
|
172
|
-
appendPrompt: boolean;
|
|
173
|
-
appendPromptSeparator?: boolean;
|
|
174
|
-
resumeArgvTemplate?: string[];
|
|
175
|
-
resumeRefCapture?: ResumeRefCapture;
|
|
176
|
-
/** FK to Provider, or null for a standalone command that runs anywhere ungated. */
|
|
177
|
-
providerId: string | null;
|
|
178
|
-
createdAt: string;
|
|
179
|
-
updatedAt: string;
|
|
180
|
-
};
|
|
181
|
-
|
|
182
147
|
export type SessionDrainStatus = "draining" | "succeeded" | "failed" | "released";
|
|
183
148
|
|
|
184
149
|
/** Bounded, durable progress for a principal session drain: cancels the authenticated principal's own queued/running sessions for one repository (not repository or host drain). */
|
|
@@ -200,11 +165,11 @@ export type SessionDrain = {
|
|
|
200
165
|
};
|
|
201
166
|
|
|
202
167
|
/**
|
|
203
|
-
* Thrown for a failed HTTP response, and also, with `status: 400`, when `createSession()`
|
|
204
|
-
* cannot resolve a `TargetSpec` name: `code ===
|
|
205
|
-
* `"UNKNOWN_COMMAND_NAME"` for no match,
|
|
206
|
-
* `"
|
|
207
|
-
* includes the matched ids.
|
|
168
|
+
* Thrown for a failed HTTP response, and also, with `status: 400`, when `createSession()` (or a
|
|
169
|
+
* drain method) cannot resolve a `TargetSpec` or `RepositoryRef` name: `code ===
|
|
170
|
+
* "UNKNOWN_PROVIDER_NAME"` / `"UNKNOWN_COMMAND_NAME"` / `"UNKNOWN_REPOSITORY_NAME"` for no match,
|
|
171
|
+
* `"AMBIGUOUS_PROVIDER_NAME"` / `"AMBIGUOUS_COMMAND_NAME"` / `"AMBIGUOUS_REPOSITORY_NAME"` for
|
|
172
|
+
* more than one match sharing a name — that message never includes the matched ids.
|
|
208
173
|
*/
|
|
209
174
|
export class AutoHarnessError extends Error {
|
|
210
175
|
status: number;
|
|
@@ -232,12 +197,35 @@ export class AutoHarnessRequestTimeoutError extends Error {
|
|
|
232
197
|
constructor(timeoutMs: number);
|
|
233
198
|
}
|
|
234
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Thrown by `waitForSessionDrain()` when its overall `timeoutMs` budget elapses.
|
|
202
|
+
*
|
|
203
|
+
* `repositoryId` echoes back whatever was passed to `waitForSessionDrain()`: a plain
|
|
204
|
+
* repository id string, or the `RepositoryRef` (e.g. `{ repositoryName }`) that was still
|
|
205
|
+
* unresolved when the deadline hit.
|
|
206
|
+
*/
|
|
207
|
+
export class AutoHarnessDrainWaitTimeoutError extends Error {
|
|
208
|
+
code: "DRAIN_WAIT_TIMEOUT";
|
|
209
|
+
repositoryId: string | RepositoryRef;
|
|
210
|
+
operationId: string;
|
|
211
|
+
timeoutMs: number;
|
|
212
|
+
constructor(repositoryId: string | RepositoryRef, operationId: string, timeoutMs: number);
|
|
213
|
+
}
|
|
214
|
+
|
|
235
215
|
export type AutoHarnessClientOptions = {
|
|
236
216
|
baseUrl: string;
|
|
237
217
|
apiKey?: string;
|
|
238
218
|
fetch?: typeof fetch;
|
|
239
219
|
/** Per-request deadline in milliseconds (default 30,000; maximum 300,000). */
|
|
240
220
|
requestTimeoutMs?: number;
|
|
221
|
+
/**
|
|
222
|
+
* Allows a non-`https` `baseUrl` while `apiKey` is set, but only when `baseUrl`'s host is
|
|
223
|
+
* genuine loopback (127.0.0.0/8, `::1`, or `localhost`) — see `isLoopbackOrigin()` in
|
|
224
|
+
* `auto-harness-client/loopback`. The constructor independently verifies this and throws for
|
|
225
|
+
* any non-loopback `http:` baseUrl even when this is `true`; a private-network address
|
|
226
|
+
* (RFC1918) still crosses real network hardware and is not loopback. Defaults to `false`.
|
|
227
|
+
*/
|
|
228
|
+
allowInsecureHttp?: boolean;
|
|
241
229
|
};
|
|
242
230
|
|
|
243
231
|
export class AutoHarnessClient {
|
|
@@ -249,11 +237,28 @@ export class AutoHarnessClient {
|
|
|
249
237
|
listSessions(options?: ListSessionsOptions): Promise<SessionPage>;
|
|
250
238
|
/** Cancels this principal's own queued/running sessions for one repository and fences new admission from it — not repository or host drain. */
|
|
251
239
|
startSessionDrain(
|
|
252
|
-
repositoryId: string,
|
|
240
|
+
repositoryId: string | RepositoryRef,
|
|
253
241
|
options?: { idempotencyKey?: string },
|
|
254
242
|
): Promise<SessionDrain>;
|
|
255
|
-
getSessionDrain(repositoryId: string, operationId: string): Promise<SessionDrain>;
|
|
256
|
-
releaseSessionDrain(
|
|
243
|
+
getSessionDrain(repositoryId: string | RepositoryRef, operationId: string): Promise<SessionDrain>;
|
|
244
|
+
releaseSessionDrain(
|
|
245
|
+
repositoryId: string | RepositoryRef,
|
|
246
|
+
operationId: string,
|
|
247
|
+
): Promise<SessionDrain>;
|
|
248
|
+
/**
|
|
249
|
+
* Resolves `repositoryId`, then polls `getSessionDrain()` until it reports a terminal status,
|
|
250
|
+
* clamping every request — including each page fetched to resolve a `repositoryName` — to the
|
|
251
|
+
* time remaining before `timeoutMs`. Resolves with the terminal `SessionDrain` for any status;
|
|
252
|
+
* callers classify success themselves. Rejects with `AutoHarnessDrainWaitTimeoutError` when the
|
|
253
|
+
* overall `timeoutMs` budget elapses — including when a clamped request is the thing that
|
|
254
|
+
* times out at that same instant — or with `AutoHarnessRequestTimeoutError` if an individual
|
|
255
|
+
* request times out while budget still remains.
|
|
256
|
+
*/
|
|
257
|
+
waitForSessionDrain(
|
|
258
|
+
repositoryId: string | RepositoryRef,
|
|
259
|
+
operationId: string,
|
|
260
|
+
options: { pollIntervalMs: number; timeoutMs: number },
|
|
261
|
+
): Promise<SessionDrain>;
|
|
257
262
|
listRepositories(options?: ListRepositoriesOptions): Promise<RepositoryPage>;
|
|
258
263
|
pauseRepository(id: string): Promise<Repository>;
|
|
259
264
|
drainRepository(id: string): Promise<Repository>;
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AutoHarnessDrainWaitTimeoutError,
|
|
3
|
+
AutoHarnessError,
|
|
4
|
+
AutoHarnessRequestTimeoutError,
|
|
5
|
+
} from "./errors.js";
|
|
6
|
+
import { assertSecureTransport } from "./loopback.js";
|
|
7
|
+
import { resolveRepositoryId } from "./resolve-repository.js";
|
|
2
8
|
import { resolveCreateSessionTargets } from "./resolve-target.js";
|
|
3
9
|
|
|
4
|
-
export { AutoHarnessError, AutoHarnessRequestTimeoutError };
|
|
10
|
+
export { AutoHarnessDrainWaitTimeoutError, AutoHarnessError, AutoHarnessRequestTimeoutError };
|
|
5
11
|
|
|
6
12
|
export class AutoHarnessClient {
|
|
7
13
|
constructor(options) {
|
|
@@ -15,6 +21,8 @@ export class AutoHarnessClient {
|
|
|
15
21
|
}
|
|
16
22
|
this.baseUrl = options.baseUrl.replace(/\/$/, "").replace(/\/api\/v1$/, "");
|
|
17
23
|
this.apiKey = options.apiKey;
|
|
24
|
+
this.allowInsecureHttp = Boolean(options.allowInsecureHttp);
|
|
25
|
+
assertSecureTransport(this.baseUrl, this.apiKey, this.allowInsecureHttp);
|
|
18
26
|
this.fetch = options.fetch ?? globalThis.fetch;
|
|
19
27
|
if (!this.fetch) throw new TypeError("fetch is required");
|
|
20
28
|
this.requestTimeoutMs = requestTimeoutMs;
|
|
@@ -108,8 +116,9 @@ export class AutoHarnessClient {
|
|
|
108
116
|
* Atomically fence this authenticated principal's session admission for one repository and
|
|
109
117
|
* begin cancelling its existing work. Reuse an idempotency key after an ambiguous retry.
|
|
110
118
|
*/
|
|
111
|
-
startSessionDrain(repositoryId, options = {}) {
|
|
112
|
-
|
|
119
|
+
async startSessionDrain(repositoryId, options = {}) {
|
|
120
|
+
const id = await resolveRepositoryId(this, repositoryId);
|
|
121
|
+
return this.request(`/repositories/${encodeURIComponent(id)}/session-drains`, {
|
|
113
122
|
method: "POST",
|
|
114
123
|
...(options.idempotencyKey === undefined
|
|
115
124
|
? {}
|
|
@@ -118,20 +127,78 @@ export class AutoHarnessClient {
|
|
|
118
127
|
}
|
|
119
128
|
|
|
120
129
|
/** Get bounded durable progress or terminal proof for one principal session drain. */
|
|
121
|
-
getSessionDrain(repositoryId, operationId) {
|
|
130
|
+
async getSessionDrain(repositoryId, operationId) {
|
|
131
|
+
const id = await resolveRepositoryId(this, repositoryId);
|
|
122
132
|
return this.request(
|
|
123
|
-
`/repositories/${encodeURIComponent(
|
|
133
|
+
`/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}`,
|
|
124
134
|
);
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/** Explicitly reopen admission after a succeeded or failed principal session drain. */
|
|
128
|
-
releaseSessionDrain(repositoryId, operationId) {
|
|
138
|
+
async releaseSessionDrain(repositoryId, operationId) {
|
|
139
|
+
const id = await resolveRepositoryId(this, repositoryId);
|
|
129
140
|
return this.request(
|
|
130
|
-
`/repositories/${encodeURIComponent(
|
|
141
|
+
`/repositories/${encodeURIComponent(id)}/session-drains/${encodeURIComponent(operationId)}/release`,
|
|
131
142
|
{ method: "POST" },
|
|
132
143
|
);
|
|
133
144
|
}
|
|
134
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Resolves `repositoryId`, then polls `getSessionDrain()` until it reports a terminal status,
|
|
148
|
+
* clamping every request — including each page fetched to resolve a `repositoryName` — to the
|
|
149
|
+
* time remaining before `timeoutMs`, recomputed fresh per request, so no single request can
|
|
150
|
+
* outlive the overall wait. Resolves with the terminal `SessionDrain` for any status, including
|
|
151
|
+
* "failed" and "released" — callers classify success themselves. Rejects with
|
|
152
|
+
* `AutoHarnessDrainWaitTimeoutError` when the overall `timeoutMs` budget elapses — including
|
|
153
|
+
* when a clamped request is the thing that times out at that same instant — or with
|
|
154
|
+
* `AutoHarnessRequestTimeoutError` if an individual request times out while budget still
|
|
155
|
+
* remains (e.g. a slow server response, well inside `timeoutMs`).
|
|
156
|
+
*/
|
|
157
|
+
async waitForSessionDrain(repositoryId, operationId, options = {}) {
|
|
158
|
+
const { pollIntervalMs, timeoutMs } = options;
|
|
159
|
+
if (!Number.isFinite(pollIntervalMs) || pollIntervalMs <= 0)
|
|
160
|
+
throw new TypeError("pollIntervalMs must be a finite positive number");
|
|
161
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
|
|
162
|
+
throw new TypeError("timeoutMs must be a finite positive number");
|
|
163
|
+
const deadline = Date.now() + timeoutMs;
|
|
164
|
+
const clampedClient = () =>
|
|
165
|
+
new AutoHarnessClient({
|
|
166
|
+
baseUrl: this.baseUrl,
|
|
167
|
+
apiKey: this.apiKey,
|
|
168
|
+
allowInsecureHttp: this.allowInsecureHttp,
|
|
169
|
+
fetch: this.fetch,
|
|
170
|
+
requestTimeoutMs: Math.max(
|
|
171
|
+
1,
|
|
172
|
+
Math.ceil(Math.min(this.requestTimeoutMs, deadline - Date.now())),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
const rejectAtDeadline = (error, idForError) =>
|
|
176
|
+
error instanceof AutoHarnessRequestTimeoutError && Date.now() >= deadline
|
|
177
|
+
? new AutoHarnessDrainWaitTimeoutError(idForError, operationId, timeoutMs)
|
|
178
|
+
: error;
|
|
179
|
+
|
|
180
|
+
const id = await resolveRepositoryId(
|
|
181
|
+
{ listRepositories: (listOptions) => clampedClient().listRepositories(listOptions) },
|
|
182
|
+
repositoryId,
|
|
183
|
+
).catch((error) => {
|
|
184
|
+
throw rejectAtDeadline(error, repositoryId);
|
|
185
|
+
});
|
|
186
|
+
for (;;) {
|
|
187
|
+
if (deadline - Date.now() <= 0) {
|
|
188
|
+
throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
|
|
189
|
+
}
|
|
190
|
+
const sessionDrain = await clampedClient()
|
|
191
|
+
.getSessionDrain(id, operationId)
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
throw rejectAtDeadline(error, id);
|
|
194
|
+
});
|
|
195
|
+
if (sessionDrain.status !== "draining") return sessionDrain;
|
|
196
|
+
const delayMs = Math.min(pollIntervalMs, deadline - Date.now());
|
|
197
|
+
if (delayMs <= 0) throw new AutoHarnessDrainWaitTimeoutError(id, operationId, timeoutMs);
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
135
202
|
listRepositories(options = {}) {
|
|
136
203
|
const query = new URLSearchParams();
|
|
137
204
|
if (options.limit !== undefined) query.set("limit", String(options.limit));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** True when `hostname` (as normalized by `URL#hostname`) is genuine loopback. */
|
|
2
|
+
export function isLoopbackHostname(hostname: string): boolean;
|
|
3
|
+
|
|
4
|
+
/** Loopback check for a `baseUrl` string; an unparseable URL is treated as non-loopback. */
|
|
5
|
+
export function isLoopbackOrigin(rawUrl: string): boolean;
|
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
|
+
}
|
package/src/resolve-target.js
CHANGED
|
@@ -1,21 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
async function resolveByName(name, catalog, kind, idKey) {
|
|
4
|
-
const matches = (await catalog()).filter((entry) => entry.name === name);
|
|
5
|
-
if (matches.length === 0) {
|
|
6
|
-
throw new AutoHarnessError(`no ${kind} named "${name}"`, {
|
|
7
|
-
status: 400,
|
|
8
|
-
code: `UNKNOWN_${kind.toUpperCase()}_NAME`,
|
|
9
|
-
});
|
|
10
|
-
}
|
|
11
|
-
if (matches.length > 1) {
|
|
12
|
-
throw new AutoHarnessError(
|
|
13
|
-
`ambiguous ${kind} name "${name}": ${matches.length} ${kind}s share this name`,
|
|
14
|
-
{ status: 400, code: `AMBIGUOUS_${kind.toUpperCase()}_NAME` },
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
return { [idKey]: matches[0].id };
|
|
18
|
-
}
|
|
1
|
+
import { resolveByName } from "./resolve-by-name.js";
|
|
2
|
+
import { resolveRepositoryId } from "./resolve-repository.js";
|
|
19
3
|
|
|
20
4
|
async function resolveRef(ref, providers, commands) {
|
|
21
5
|
if (ref == null || typeof ref !== "object") return ref;
|
|
@@ -23,21 +7,21 @@ async function resolveRef(ref, providers, commands) {
|
|
|
23
7
|
// explicit `providerId: undefined`, which `in` would treat as already resolved.
|
|
24
8
|
if (ref.providerId !== undefined || ref.commandId !== undefined) return ref;
|
|
25
9
|
if (ref.providerName !== undefined) {
|
|
26
|
-
return resolveByName(ref.providerName, providers, "provider"
|
|
10
|
+
return { providerId: await resolveByName(ref.providerName, providers, "provider") };
|
|
27
11
|
}
|
|
28
12
|
if (ref.commandName !== undefined) {
|
|
29
|
-
return resolveByName(ref.commandName, commands, "command"
|
|
13
|
+
return { commandId: await resolveByName(ref.commandName, commands, "command") };
|
|
30
14
|
}
|
|
31
15
|
return ref;
|
|
32
16
|
}
|
|
33
17
|
|
|
34
18
|
/**
|
|
35
|
-
* Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks
|
|
36
|
-
* `
|
|
37
|
-
* each regardless of how many refs need
|
|
38
|
-
* all-id call makes no extra requests.
|
|
39
|
-
* against more than one match
|
|
40
|
-
*
|
|
19
|
+
* Resolves `providerName`/`commandName` entries in `input.target`/`input.fallbacks`, and
|
|
20
|
+
* `input.repositoryName`, to their ids via `client.listProviders()`/`listCommands()`/
|
|
21
|
+
* `listRepositories()`, each called at most once regardless of how many refs need it. Id-shaped
|
|
22
|
+
* refs pass through untouched, so an all-id call makes no extra requests. Names are resolved
|
|
23
|
+
* defensively against more than one match: create/update checks are read-then-write races rather
|
|
24
|
+
* than atomic constraints, and legacy catalog rows are not rewritten.
|
|
41
25
|
*/
|
|
42
26
|
export async function resolveCreateSessionTargets(client, input) {
|
|
43
27
|
let providersPromise;
|
|
@@ -46,9 +30,17 @@ export async function resolveCreateSessionTargets(client, input) {
|
|
|
46
30
|
const commands = () => (commandsPromise ??= client.listCommands());
|
|
47
31
|
|
|
48
32
|
const target = await resolveRef(input.target, providers, commands);
|
|
49
|
-
|
|
33
|
+
const repositoryId = await resolveRepositoryId(
|
|
34
|
+
client,
|
|
35
|
+
input.repositoryId !== undefined
|
|
36
|
+
? input.repositoryId
|
|
37
|
+
: { repositoryName: input.repositoryName },
|
|
38
|
+
);
|
|
39
|
+
const resolved = { ...input, repositoryId, target };
|
|
40
|
+
delete resolved.repositoryName;
|
|
41
|
+
if (input.fallbacks === undefined) return resolved;
|
|
50
42
|
const fallbacks = await Promise.all(
|
|
51
43
|
input.fallbacks.map((fallback) => resolveRef(fallback, providers, commands)),
|
|
52
44
|
);
|
|
53
|
-
return { ...
|
|
45
|
+
return { ...resolved, fallbacks };
|
|
54
46
|
}
|