@peakflo/agent-harness 0.0.0-stage → 0.2.1-rc.3
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { ControlPlaneErrorBody, CredentialRotateResponse, DeregisterResponse, IngestRequest, IngestResponse, LeaseRenewResponse, LeaseResponse, RunnerCapabilities, RunnerHeartbeatResponse, RunnerRegisterResponse, RunnerRuntime, RunnerStatus, SessionHistoryResponse } from './types';
|
|
2
|
+
export declare class ControlPlaneError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
/** The stable machine-readable code from the body, when the server sent one. */
|
|
5
|
+
readonly code?: string;
|
|
6
|
+
/** The floor an operator needs to see on a protocol refusal. */
|
|
7
|
+
readonly minProtocolVersion?: number;
|
|
8
|
+
readonly minHarnessVersion?: string;
|
|
9
|
+
constructor(message: string, status: number, body?: ControlPlaneErrorBody);
|
|
10
|
+
/**
|
|
11
|
+
* Is retrying this pointless?
|
|
12
|
+
*
|
|
13
|
+
* Delegates to the SHARED predicate rather than inlining a status list. The
|
|
14
|
+
* Phase 0 skeleton inlined [400, 401, 403, 404, 409] and so did not treat
|
|
15
|
+
* 426 Upgrade Required as final — which meant the RUNNING-FLEET version gate
|
|
16
|
+
* never took effect: a below-floor harness retried for ever instead of
|
|
17
|
+
* exiting. Import the constant; never retype the list.
|
|
18
|
+
*/
|
|
19
|
+
get final(): boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A 426 at heartbeat, lease or ingest. The control plane raised its floor under
|
|
23
|
+
* a runner that was already registered.
|
|
24
|
+
*
|
|
25
|
+
* A distinct class rather than a message match, so rewording an error string
|
|
26
|
+
* cannot quietly turn a fail-fast into a retry loop.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ProtocolRefusedError extends ControlPlaneError {
|
|
29
|
+
constructor(message: string, body?: ControlPlaneErrorBody);
|
|
30
|
+
}
|
|
31
|
+
export interface RegisterInput {
|
|
32
|
+
enrolmentToken: string;
|
|
33
|
+
capabilities: RunnerCapabilities;
|
|
34
|
+
runtime: RunnerRuntime;
|
|
35
|
+
degraded: boolean;
|
|
36
|
+
degradedReason?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface HeartbeatInput {
|
|
39
|
+
status: RunnerStatus;
|
|
40
|
+
uptimeMs: number;
|
|
41
|
+
activeSessions: number;
|
|
42
|
+
activeSessionIds?: string[];
|
|
43
|
+
degraded: boolean;
|
|
44
|
+
capabilities: RunnerCapabilities;
|
|
45
|
+
}
|
|
46
|
+
export interface ControlPlaneClientOptions {
|
|
47
|
+
/** Injected in tests. Defaults to the global fetch. */
|
|
48
|
+
fetchImpl?: typeof fetch;
|
|
49
|
+
/** Injected in tests so back-off does not cost real wall-clock. */
|
|
50
|
+
sleepImpl?: (ms: number) => Promise<void>;
|
|
51
|
+
maxAttempts?: number;
|
|
52
|
+
baseDelayMs?: number;
|
|
53
|
+
}
|
|
54
|
+
export interface IsolatedLeaseRenewalConfig {
|
|
55
|
+
url: string;
|
|
56
|
+
runnerCredential: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* THE OUTBOUND CONTROL-PLANE CLIENT.
|
|
60
|
+
*
|
|
61
|
+
* Every method here DIALS OUT. There is no server, no listener and no inverse
|
|
62
|
+
* channel, and there must never be one: the moment the control plane needs to
|
|
63
|
+
* dial a runner, a customer VM behind NAT stops working without a tunnel. Work
|
|
64
|
+
* arrives on `lease()`, a bounded long-poll; anything that must reach a RUNNING
|
|
65
|
+
* session — an abort, a user's answer — rides back on the next `heartbeat()`.
|
|
66
|
+
*
|
|
67
|
+
* The runner credential lives in this object and NOWHERE ELSE. It is never
|
|
68
|
+
* written to disk, never handed to a session process and never placed in argv.
|
|
69
|
+
*/
|
|
70
|
+
export declare class ControlPlaneClient {
|
|
71
|
+
private readonly baseUrl;
|
|
72
|
+
private readonly fetchImpl;
|
|
73
|
+
private readonly sleepImpl;
|
|
74
|
+
private readonly maxAttempts;
|
|
75
|
+
private readonly baseDelayMs;
|
|
76
|
+
private runnerCredential;
|
|
77
|
+
private runnerId;
|
|
78
|
+
constructor(controlPlaneUrl: string, options?: ControlPlaneClientOptions);
|
|
79
|
+
/** True once a real, non-empty runner credential is held. */
|
|
80
|
+
isRegistered(): boolean;
|
|
81
|
+
/** Used only for a private IPC handoff to the baked sandbox supervisor. */
|
|
82
|
+
getRunnerCredential(): string | null;
|
|
83
|
+
getRunnerId(): string | null;
|
|
84
|
+
/**
|
|
85
|
+
* Give the lease-renewal worker the minimum information it needs.
|
|
86
|
+
*
|
|
87
|
+
* The worker owns only this one authenticated route. It cannot lease work,
|
|
88
|
+
* ingest a transcript, or call an integration. A custom fetch implementation
|
|
89
|
+
* means a test or embedded host owns transport, so those clients keep using
|
|
90
|
+
* the in-process renewer.
|
|
91
|
+
*/
|
|
92
|
+
isolatedLeaseRenewalConfig(): IsolatedLeaseRenewalConfig | null;
|
|
93
|
+
/**
|
|
94
|
+
* Install a PERSISTED runner credential without going through enrolment.
|
|
95
|
+
*
|
|
96
|
+
* This is the reuse half of durable credential storage: a self-hosted or
|
|
97
|
+
* desktop harness that crashed and restarted loads its stored credential and
|
|
98
|
+
* installs it here, so `heartbeat`, `lease` and `ingest` authenticate exactly
|
|
99
|
+
* as they would after a fresh `register`. The credential is validated by the
|
|
100
|
+
* first authenticated call; a rejected one is dropped by the caller and
|
|
101
|
+
* enrolment is retried. See `credential-store.ts`.
|
|
102
|
+
*/
|
|
103
|
+
useStoredCredential(runnerId: string, credential: string): void;
|
|
104
|
+
/** Drop the in-memory credential. Called on shutdown. */
|
|
105
|
+
forgetCredential(): void;
|
|
106
|
+
/**
|
|
107
|
+
* Exchange the enrolment token for a long-lived runner credential.
|
|
108
|
+
*
|
|
109
|
+
* Retries with exponential back-off, because a control plane that is briefly
|
|
110
|
+
* unreachable must not kill a host that has already booted. A FINAL status
|
|
111
|
+
* is never retried: a spent enrolment token would otherwise burn six attempts
|
|
112
|
+
* over ~31 seconds before failing with the same answer.
|
|
113
|
+
*/
|
|
114
|
+
register(input: RegisterInput): Promise<RunnerRegisterResponse>;
|
|
115
|
+
/**
|
|
116
|
+
* Liveness plus capability refresh, and the ONLY inbound-shaped channel the
|
|
117
|
+
* contract has: `sessionCommands` on the response is how an abort or an
|
|
118
|
+
* answer to a question reaches a running session without anything dialling in.
|
|
119
|
+
*/
|
|
120
|
+
heartbeat(input: HeartbeatInput): Promise<RunnerHeartbeatResponse>;
|
|
121
|
+
/**
|
|
122
|
+
* The bounded long-poll that replaces a socket.
|
|
123
|
+
*
|
|
124
|
+
* Returns null on 204 (no work) — poll again immediately. NOT retried here:
|
|
125
|
+
* the caller's loop IS the retry, and layering back-off inside a poll that is
|
|
126
|
+
* already bounded would only make a runner slower to pick work up.
|
|
127
|
+
*
|
|
128
|
+
* The client timeout is `wait + slack`, never the flat request timeout — a
|
|
129
|
+
* 25s poll under a 15s timeout would abort every single call.
|
|
130
|
+
*/
|
|
131
|
+
lease(waitSeconds?: number): Promise<LeaseResponse | null>;
|
|
132
|
+
renewLease(leaseId: string, status?: RunnerStatus, githubTokenExpiresAt?: string, modelTokenExpiresAt?: string): Promise<LeaseRenewResponse>;
|
|
133
|
+
/**
|
|
134
|
+
* THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
|
|
135
|
+
* desktop all call exactly this.
|
|
136
|
+
*
|
|
137
|
+
* Retried with back-off, because losing transcript to a transient blip is a
|
|
138
|
+
* visible product defect — the user watches text stop arriving. Retrying is
|
|
139
|
+
* safe: the upsert is keyed on (sessionId, partId), so a replayed batch is
|
|
140
|
+
* idempotent.
|
|
141
|
+
*/
|
|
142
|
+
ingest(sessionId: string, request: Omit<IngestRequest, 'protocolVersion'>): Promise<IngestResponse>;
|
|
143
|
+
/**
|
|
144
|
+
* Read the session's model-facing conversation back from the control plane.
|
|
145
|
+
*
|
|
146
|
+
* Called only when this runner does not already hold the conversation — see
|
|
147
|
+
* `SessionRunner.loadMemory`, which uses `resumeFromRev` to decide. NOT
|
|
148
|
+
* retried on the shared retry ladder: it is on the critical path of a turn
|
|
149
|
+
* the user is watching, and the caller already degrades gracefully to
|
|
150
|
+
* whatever it holds locally rather than failing the turn.
|
|
151
|
+
*/
|
|
152
|
+
sessionHistory(sessionId: string, leaseId: string): Promise<SessionHistoryResponse>;
|
|
153
|
+
/**
|
|
154
|
+
* Authenticated with the CURRENT credential, which is why a harness must
|
|
155
|
+
* rotate ON SCHEDULE (`rotateAfter`) and must NOT try to rotate its way out
|
|
156
|
+
* of a 401. An expired credential cannot authenticate its own replacement,
|
|
157
|
+
* so a harness that tried would loop between two routes that can only fail.
|
|
158
|
+
*/
|
|
159
|
+
rotateCredential(harnessVersion: string): Promise<CredentialRotateResponse>;
|
|
160
|
+
/**
|
|
161
|
+
* A polite goodbye, and the one route that must never block a shutdown. A
|
|
162
|
+
* failure here is logged by the caller and ignored: liveness is the 30s
|
|
163
|
+
* heartbeat, and the watchdog requeues in-flight leases regardless.
|
|
164
|
+
*/
|
|
165
|
+
deregister(reason?: string, hadActiveSessions?: boolean): Promise<DeregisterResponse>;
|
|
166
|
+
private requireCredential;
|
|
167
|
+
private requireRunnerId;
|
|
168
|
+
/**
|
|
169
|
+
* Exponential back-off, capped by attempt count rather than by wall clock so
|
|
170
|
+
* that a caller can reason about the worst case: ~31s at the defaults.
|
|
171
|
+
*
|
|
172
|
+
* A FINAL status short-circuits immediately. That is the whole reason
|
|
173
|
+
* `FINAL_HTTP_STATUSES` is shared with the control plane instead of being
|
|
174
|
+
* retyped on each side.
|
|
175
|
+
*/
|
|
176
|
+
private withRetry;
|
|
177
|
+
private send;
|
|
178
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ControlPlaneClient = exports.ProtocolRefusedError = exports.ControlPlaneError = void 0;
|
|
4
|
+
const contract_1 = require("./contract");
|
|
5
|
+
/**
|
|
6
|
+
* Default request timeout. Deliberately NOT applied to the lease long-poll,
|
|
7
|
+
* which is bounded by its own `wait` and gets `wait + slack`.
|
|
8
|
+
*/
|
|
9
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
10
|
+
/** Slack over the requested lease wait before the client gives up on its own. */
|
|
11
|
+
const LEASE_TIMEOUT_SLACK_MS = 10_000;
|
|
12
|
+
function safeParse(text) {
|
|
13
|
+
if (!text) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// A non-JSON error body is perfectly possible from a proxy. The STATUS is
|
|
21
|
+
// what the retry decision rests on, so an unparseable body costs nothing.
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
class ControlPlaneError extends Error {
|
|
26
|
+
status;
|
|
27
|
+
/** The stable machine-readable code from the body, when the server sent one. */
|
|
28
|
+
code;
|
|
29
|
+
/** The floor an operator needs to see on a protocol refusal. */
|
|
30
|
+
minProtocolVersion;
|
|
31
|
+
minHarnessVersion;
|
|
32
|
+
constructor(message, status, body) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'ControlPlaneError';
|
|
35
|
+
this.status = status;
|
|
36
|
+
this.code = body?.code;
|
|
37
|
+
this.minProtocolVersion = body?.minProtocolVersion;
|
|
38
|
+
this.minHarnessVersion = body?.minHarnessVersion;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Is retrying this pointless?
|
|
42
|
+
*
|
|
43
|
+
* Delegates to the SHARED predicate rather than inlining a status list. The
|
|
44
|
+
* Phase 0 skeleton inlined [400, 401, 403, 404, 409] and so did not treat
|
|
45
|
+
* 426 Upgrade Required as final — which meant the RUNNING-FLEET version gate
|
|
46
|
+
* never took effect: a below-floor harness retried for ever instead of
|
|
47
|
+
* exiting. Import the constant; never retype the list.
|
|
48
|
+
*/
|
|
49
|
+
get final() {
|
|
50
|
+
return (0, contract_1.isFinalStatus)(this.status);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.ControlPlaneError = ControlPlaneError;
|
|
54
|
+
/**
|
|
55
|
+
* A 426 at heartbeat, lease or ingest. The control plane raised its floor under
|
|
56
|
+
* a runner that was already registered.
|
|
57
|
+
*
|
|
58
|
+
* A distinct class rather than a message match, so rewording an error string
|
|
59
|
+
* cannot quietly turn a fail-fast into a retry loop.
|
|
60
|
+
*/
|
|
61
|
+
class ProtocolRefusedError extends ControlPlaneError {
|
|
62
|
+
constructor(message, body) {
|
|
63
|
+
super(message, 426, body);
|
|
64
|
+
this.name = 'ProtocolRefusedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
exports.ProtocolRefusedError = ProtocolRefusedError;
|
|
68
|
+
function sleep(ms) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
setTimeout(resolve, ms);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* THE OUTBOUND CONTROL-PLANE CLIENT.
|
|
75
|
+
*
|
|
76
|
+
* Every method here DIALS OUT. There is no server, no listener and no inverse
|
|
77
|
+
* channel, and there must never be one: the moment the control plane needs to
|
|
78
|
+
* dial a runner, a customer VM behind NAT stops working without a tunnel. Work
|
|
79
|
+
* arrives on `lease()`, a bounded long-poll; anything that must reach a RUNNING
|
|
80
|
+
* session — an abort, a user's answer — rides back on the next `heartbeat()`.
|
|
81
|
+
*
|
|
82
|
+
* The runner credential lives in this object and NOWHERE ELSE. It is never
|
|
83
|
+
* written to disk, never handed to a session process and never placed in argv.
|
|
84
|
+
*/
|
|
85
|
+
class ControlPlaneClient {
|
|
86
|
+
baseUrl;
|
|
87
|
+
fetchImpl;
|
|
88
|
+
sleepImpl;
|
|
89
|
+
maxAttempts;
|
|
90
|
+
baseDelayMs;
|
|
91
|
+
runnerCredential = null;
|
|
92
|
+
runnerId = null;
|
|
93
|
+
constructor(controlPlaneUrl, options = {}) {
|
|
94
|
+
this.baseUrl = controlPlaneUrl.replace(/\/+$/, '');
|
|
95
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
96
|
+
this.sleepImpl = options.sleepImpl ?? sleep;
|
|
97
|
+
this.maxAttempts = options.maxAttempts ?? contract_1.DEFAULT_MAX_ATTEMPTS;
|
|
98
|
+
this.baseDelayMs = options.baseDelayMs ?? contract_1.DEFAULT_BASE_DELAY_MS;
|
|
99
|
+
}
|
|
100
|
+
/* ------------------------------------------------------------------ */
|
|
101
|
+
/* Identity */
|
|
102
|
+
/* ------------------------------------------------------------------ */
|
|
103
|
+
/** True once a real, non-empty runner credential is held. */
|
|
104
|
+
isRegistered() {
|
|
105
|
+
return (typeof this.runnerCredential === 'string' &&
|
|
106
|
+
this.runnerCredential.length > 0 &&
|
|
107
|
+
typeof this.runnerId === 'string' &&
|
|
108
|
+
this.runnerId.length > 0);
|
|
109
|
+
}
|
|
110
|
+
/** Used only for a private IPC handoff to the baked sandbox supervisor. */
|
|
111
|
+
getRunnerCredential() {
|
|
112
|
+
return this.runnerCredential;
|
|
113
|
+
}
|
|
114
|
+
getRunnerId() {
|
|
115
|
+
return this.runnerId;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Give the lease-renewal worker the minimum information it needs.
|
|
119
|
+
*
|
|
120
|
+
* The worker owns only this one authenticated route. It cannot lease work,
|
|
121
|
+
* ingest a transcript, or call an integration. A custom fetch implementation
|
|
122
|
+
* means a test or embedded host owns transport, so those clients keep using
|
|
123
|
+
* the in-process renewer.
|
|
124
|
+
*/
|
|
125
|
+
isolatedLeaseRenewalConfig() {
|
|
126
|
+
if (this.fetchImpl !== fetch || !this.runnerId || !this.runnerCredential) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
url: `${this.baseUrl}${contract_1.ROUTES.leaseRenew(this.runnerId, '__LEASE_ID__')}`,
|
|
131
|
+
runnerCredential: this.runnerCredential
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Install a PERSISTED runner credential without going through enrolment.
|
|
136
|
+
*
|
|
137
|
+
* This is the reuse half of durable credential storage: a self-hosted or
|
|
138
|
+
* desktop harness that crashed and restarted loads its stored credential and
|
|
139
|
+
* installs it here, so `heartbeat`, `lease` and `ingest` authenticate exactly
|
|
140
|
+
* as they would after a fresh `register`. The credential is validated by the
|
|
141
|
+
* first authenticated call; a rejected one is dropped by the caller and
|
|
142
|
+
* enrolment is retried. See `credential-store.ts`.
|
|
143
|
+
*/
|
|
144
|
+
useStoredCredential(runnerId, credential) {
|
|
145
|
+
this.runnerId = runnerId;
|
|
146
|
+
this.runnerCredential = credential;
|
|
147
|
+
}
|
|
148
|
+
/** Drop the in-memory credential. Called on shutdown. */
|
|
149
|
+
forgetCredential() {
|
|
150
|
+
this.runnerCredential = null;
|
|
151
|
+
}
|
|
152
|
+
/* ------------------------------------------------------------------ */
|
|
153
|
+
/* 1. register */
|
|
154
|
+
/* ------------------------------------------------------------------ */
|
|
155
|
+
/**
|
|
156
|
+
* Exchange the enrolment token for a long-lived runner credential.
|
|
157
|
+
*
|
|
158
|
+
* Retries with exponential back-off, because a control plane that is briefly
|
|
159
|
+
* unreachable must not kill a host that has already booted. A FINAL status
|
|
160
|
+
* is never retried: a spent enrolment token would otherwise burn six attempts
|
|
161
|
+
* over ~31 seconds before failing with the same answer.
|
|
162
|
+
*/
|
|
163
|
+
async register(input) {
|
|
164
|
+
const response = await this.withRetry('register', () => this.send({
|
|
165
|
+
method: 'POST',
|
|
166
|
+
path: contract_1.ROUTES.register(),
|
|
167
|
+
token: input.enrolmentToken,
|
|
168
|
+
body: {
|
|
169
|
+
protocolVersion: contract_1.PROTOCOL_VERSION,
|
|
170
|
+
capabilities: input.capabilities,
|
|
171
|
+
runtime: input.runtime,
|
|
172
|
+
degraded: input.degraded,
|
|
173
|
+
degradedReason: input.degradedReason
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
// THE VERSION GATE MUST FAIL CLOSED.
|
|
177
|
+
//
|
|
178
|
+
// Testing only `accepted === false` fails OPEN when a 200 omits the field.
|
|
179
|
+
// A successful registration always carries BOTH a credential and a runner
|
|
180
|
+
// id, so those are the real invariant: no credential means not accepted,
|
|
181
|
+
// whatever the response says about itself.
|
|
182
|
+
const credential = typeof response.runnerCredential === 'string' &&
|
|
183
|
+
response.runnerCredential.length > 0
|
|
184
|
+
? response.runnerCredential
|
|
185
|
+
: null;
|
|
186
|
+
const id = typeof response.runnerId === 'string' && response.runnerId.length > 0
|
|
187
|
+
? response.runnerId
|
|
188
|
+
: null;
|
|
189
|
+
if (response.accepted === false || !credential || !id) {
|
|
190
|
+
this.runnerCredential = null;
|
|
191
|
+
this.runnerId = null;
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
this.runnerCredential = credential;
|
|
195
|
+
this.runnerId = id;
|
|
196
|
+
return response;
|
|
197
|
+
}
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
199
|
+
/* 2. heartbeat */
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
/**
|
|
202
|
+
* Liveness plus capability refresh, and the ONLY inbound-shaped channel the
|
|
203
|
+
* contract has: `sessionCommands` on the response is how an abort or an
|
|
204
|
+
* answer to a question reaches a running session without anything dialling in.
|
|
205
|
+
*/
|
|
206
|
+
async heartbeat(input) {
|
|
207
|
+
const runnerId = this.requireRunnerId();
|
|
208
|
+
const body = {
|
|
209
|
+
protocolVersion: contract_1.PROTOCOL_VERSION,
|
|
210
|
+
status: input.status,
|
|
211
|
+
uptimeMs: input.uptimeMs,
|
|
212
|
+
activeSessions: input.activeSessions,
|
|
213
|
+
activeSessionIds: input.activeSessionIds,
|
|
214
|
+
degraded: input.degraded,
|
|
215
|
+
capabilities: input.capabilities
|
|
216
|
+
};
|
|
217
|
+
return this.send({
|
|
218
|
+
method: 'POST',
|
|
219
|
+
path: contract_1.ROUTES.heartbeat(runnerId),
|
|
220
|
+
token: this.requireCredential(),
|
|
221
|
+
body
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/* ------------------------------------------------------------------ */
|
|
225
|
+
/* 3. lease */
|
|
226
|
+
/* ------------------------------------------------------------------ */
|
|
227
|
+
/**
|
|
228
|
+
* The bounded long-poll that replaces a socket.
|
|
229
|
+
*
|
|
230
|
+
* Returns null on 204 (no work) — poll again immediately. NOT retried here:
|
|
231
|
+
* the caller's loop IS the retry, and layering back-off inside a poll that is
|
|
232
|
+
* already bounded would only make a runner slower to pick work up.
|
|
233
|
+
*
|
|
234
|
+
* The client timeout is `wait + slack`, never the flat request timeout — a
|
|
235
|
+
* 25s poll under a 15s timeout would abort every single call.
|
|
236
|
+
*/
|
|
237
|
+
async lease(waitSeconds = contract_1.DEFAULT_LEASE_WAIT_SECONDS) {
|
|
238
|
+
const runnerId = this.requireRunnerId();
|
|
239
|
+
const path = contract_1.ROUTES.lease(runnerId, waitSeconds);
|
|
240
|
+
const result = await this.send({
|
|
241
|
+
method: 'GET',
|
|
242
|
+
path,
|
|
243
|
+
token: this.requireCredential(),
|
|
244
|
+
timeoutMs: waitSeconds * 1_000 + LEASE_TIMEOUT_SLACK_MS
|
|
245
|
+
});
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
/* ------------------------------------------------------------------ */
|
|
249
|
+
/* 4. lease renew */
|
|
250
|
+
/* ------------------------------------------------------------------ */
|
|
251
|
+
async renewLease(leaseId, status, githubTokenExpiresAt, modelTokenExpiresAt) {
|
|
252
|
+
const runnerId = this.requireRunnerId();
|
|
253
|
+
return this.send({
|
|
254
|
+
method: 'POST',
|
|
255
|
+
path: contract_1.ROUTES.leaseRenew(runnerId, leaseId),
|
|
256
|
+
token: this.requireCredential(),
|
|
257
|
+
body: {
|
|
258
|
+
protocolVersion: contract_1.PROTOCOL_VERSION,
|
|
259
|
+
status,
|
|
260
|
+
githubTokenExpiresAt,
|
|
261
|
+
modelTokenExpiresAt
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
/* ------------------------------------------------------------------ */
|
|
266
|
+
/* 5. ingest */
|
|
267
|
+
/* ------------------------------------------------------------------ */
|
|
268
|
+
/**
|
|
269
|
+
* THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
|
|
270
|
+
* desktop all call exactly this.
|
|
271
|
+
*
|
|
272
|
+
* Retried with back-off, because losing transcript to a transient blip is a
|
|
273
|
+
* visible product defect — the user watches text stop arriving. Retrying is
|
|
274
|
+
* safe: the upsert is keyed on (sessionId, partId), so a replayed batch is
|
|
275
|
+
* idempotent.
|
|
276
|
+
*/
|
|
277
|
+
async ingest(sessionId, request) {
|
|
278
|
+
return this.withRetry('ingest', () => this.send({
|
|
279
|
+
method: 'POST',
|
|
280
|
+
path: contract_1.ROUTES.ingest(sessionId),
|
|
281
|
+
token: this.requireCredential(),
|
|
282
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, ...request }
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
/* ------------------------------------------------------------------ */
|
|
286
|
+
/* 5b. session history — the runner's READ path */
|
|
287
|
+
/* ------------------------------------------------------------------ */
|
|
288
|
+
/**
|
|
289
|
+
* Read the session's model-facing conversation back from the control plane.
|
|
290
|
+
*
|
|
291
|
+
* Called only when this runner does not already hold the conversation — see
|
|
292
|
+
* `SessionRunner.loadMemory`, which uses `resumeFromRev` to decide. NOT
|
|
293
|
+
* retried on the shared retry ladder: it is on the critical path of a turn
|
|
294
|
+
* the user is watching, and the caller already degrades gracefully to
|
|
295
|
+
* whatever it holds locally rather than failing the turn.
|
|
296
|
+
*/
|
|
297
|
+
async sessionHistory(sessionId, leaseId) {
|
|
298
|
+
return this.send({
|
|
299
|
+
method: 'GET',
|
|
300
|
+
path: contract_1.ROUTES.sessionHistory(sessionId, leaseId),
|
|
301
|
+
token: this.requireCredential()
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
/* ------------------------------------------------------------------ */
|
|
305
|
+
/* 6. credential rotate */
|
|
306
|
+
/* ------------------------------------------------------------------ */
|
|
307
|
+
/**
|
|
308
|
+
* Authenticated with the CURRENT credential, which is why a harness must
|
|
309
|
+
* rotate ON SCHEDULE (`rotateAfter`) and must NOT try to rotate its way out
|
|
310
|
+
* of a 401. An expired credential cannot authenticate its own replacement,
|
|
311
|
+
* so a harness that tried would loop between two routes that can only fail.
|
|
312
|
+
*/
|
|
313
|
+
async rotateCredential(harnessVersion) {
|
|
314
|
+
const runnerId = this.requireRunnerId();
|
|
315
|
+
const response = await this.send({
|
|
316
|
+
method: 'POST',
|
|
317
|
+
path: contract_1.ROUTES.credentialRotate(runnerId),
|
|
318
|
+
token: this.requireCredential(),
|
|
319
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, harnessVersion }
|
|
320
|
+
});
|
|
321
|
+
if (typeof response.runnerCredential === 'string' &&
|
|
322
|
+
response.runnerCredential.length > 0) {
|
|
323
|
+
this.runnerCredential = response.runnerCredential;
|
|
324
|
+
}
|
|
325
|
+
return response;
|
|
326
|
+
}
|
|
327
|
+
/* ------------------------------------------------------------------ */
|
|
328
|
+
/* 7. deregister */
|
|
329
|
+
/* ------------------------------------------------------------------ */
|
|
330
|
+
/**
|
|
331
|
+
* A polite goodbye, and the one route that must never block a shutdown. A
|
|
332
|
+
* failure here is logged by the caller and ignored: liveness is the 30s
|
|
333
|
+
* heartbeat, and the watchdog requeues in-flight leases regardless.
|
|
334
|
+
*/
|
|
335
|
+
async deregister(reason, hadActiveSessions) {
|
|
336
|
+
const runnerId = this.requireRunnerId();
|
|
337
|
+
return this.send({
|
|
338
|
+
method: 'POST',
|
|
339
|
+
path: contract_1.ROUTES.deregister(runnerId),
|
|
340
|
+
token: this.requireCredential(),
|
|
341
|
+
body: { protocolVersion: contract_1.PROTOCOL_VERSION, reason, hadActiveSessions }
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/* ------------------------------------------------------------------ */
|
|
345
|
+
/* Plumbing */
|
|
346
|
+
/* ------------------------------------------------------------------ */
|
|
347
|
+
requireCredential() {
|
|
348
|
+
if (!this.runnerCredential) {
|
|
349
|
+
throw new ControlPlaneError('Not registered', 401, {
|
|
350
|
+
error: 'Not registered',
|
|
351
|
+
code: contract_1.CONTRACT_ERROR_CODES.credentialInvalid
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
return this.runnerCredential;
|
|
355
|
+
}
|
|
356
|
+
requireRunnerId() {
|
|
357
|
+
if (!this.runnerId) {
|
|
358
|
+
throw new ControlPlaneError('Not registered', 401, {
|
|
359
|
+
error: 'Not registered',
|
|
360
|
+
code: contract_1.CONTRACT_ERROR_CODES.runnerUnknown
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return this.runnerId;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Exponential back-off, capped by attempt count rather than by wall clock so
|
|
367
|
+
* that a caller can reason about the worst case: ~31s at the defaults.
|
|
368
|
+
*
|
|
369
|
+
* A FINAL status short-circuits immediately. That is the whole reason
|
|
370
|
+
* `FINAL_HTTP_STATUSES` is shared with the control plane instead of being
|
|
371
|
+
* retyped on each side.
|
|
372
|
+
*/
|
|
373
|
+
async withRetry(label, attemptFn) {
|
|
374
|
+
let lastError;
|
|
375
|
+
for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
|
|
376
|
+
try {
|
|
377
|
+
return await attemptFn();
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
lastError = error;
|
|
381
|
+
if (error instanceof ControlPlaneError && error.final) {
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
if (attempt < this.maxAttempts) {
|
|
385
|
+
await this.sleepImpl(this.baseDelayMs * 2 ** (attempt - 1));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw lastError instanceof Error ? lastError : new Error(`${label} failed`);
|
|
390
|
+
}
|
|
391
|
+
async send(input) {
|
|
392
|
+
const controller = new AbortController();
|
|
393
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
394
|
+
try {
|
|
395
|
+
const headers = {
|
|
396
|
+
Authorization: `Bearer ${input.token}`,
|
|
397
|
+
// An OPTIONAL echo of the body's protocolVersion, so a proxy or an
|
|
398
|
+
// access log can see the version without parsing JSON. THE BODY STAYS
|
|
399
|
+
// AUTHORITATIVE; a server that finds the two disagreeing must refuse.
|
|
400
|
+
[contract_1.PROTOCOL_VERSION_HEADER]: String(contract_1.PROTOCOL_VERSION)
|
|
401
|
+
};
|
|
402
|
+
if (input.body !== undefined) {
|
|
403
|
+
headers['Content-Type'] = 'application/json';
|
|
404
|
+
}
|
|
405
|
+
const response = await this.fetchImpl(`${this.baseUrl}${input.path}`, {
|
|
406
|
+
method: input.method,
|
|
407
|
+
headers,
|
|
408
|
+
body: input.body === undefined ? undefined : JSON.stringify(input.body),
|
|
409
|
+
signal: controller.signal
|
|
410
|
+
});
|
|
411
|
+
// 204 is a real answer on the lease route: "no work, poll again". It is
|
|
412
|
+
// not an error and must not be parsed as a body.
|
|
413
|
+
if (response.status === 204) {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const text = await response.text();
|
|
417
|
+
if (!response.ok) {
|
|
418
|
+
const parsed = safeParse(text);
|
|
419
|
+
const message = `${input.path} returned ${response.status}: ${text.slice(0, 400)}`;
|
|
420
|
+
throw response.status === 426
|
|
421
|
+
? new ProtocolRefusedError(message, parsed)
|
|
422
|
+
: new ControlPlaneError(message, response.status, parsed);
|
|
423
|
+
}
|
|
424
|
+
return (text ? JSON.parse(text) : {});
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
clearTimeout(timer);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
exports.ControlPlaneClient = ControlPlaneClient;
|