@peakflo/agent-harness 0.0.0-stage → 0.2.2-rc.1
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 +68 -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
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RUNNER WIRE CONTRACT — the single meeting point between the control
|
|
3
|
+
* plane (Track A, workflow-builder) and the harness (Track B, extracted from
|
|
4
|
+
* 20x).
|
|
5
|
+
*
|
|
6
|
+
* This file is the contract. `packages/agentSandbox/test/control-plane-stub.mjs`
|
|
7
|
+
* is the SAME contract in executable form. The two are kept in lockstep on
|
|
8
|
+
* purpose: prose drifts, a running stub does not.
|
|
9
|
+
*
|
|
10
|
+
* FOUR PROPERTIES THAT ARE NOT NEGOTIABLE
|
|
11
|
+
*
|
|
12
|
+
* 1. VERSIONED. Every request carries `protocolVersion`. A harness below the
|
|
13
|
+
* control plane's floor is refused, and the refusal is FINAL — the harness
|
|
14
|
+
* exits so the host is recycled onto a supported version. See
|
|
15
|
+
* `./contract.ts`.
|
|
16
|
+
*
|
|
17
|
+
* 2. OUTBOUND ONLY. Nothing here requires the harness to accept an inbound
|
|
18
|
+
* connection. Work is delivered by a bounded long-poll (`GET .../lease`),
|
|
19
|
+
* never by the control plane dialling in. That single property is what lets
|
|
20
|
+
* a customer VM behind NAT work with no tunnel and no listening port.
|
|
21
|
+
* Anything that needs to reach a running harness — abort, an answer to a
|
|
22
|
+
* question — travels back on the NEXT poll, which is why
|
|
23
|
+
* `RunnerHeartbeatResponse.sessionCommands` exists.
|
|
24
|
+
*
|
|
25
|
+
* 3. RUNTIME NEUTRAL. A Daytona sandbox, a customer VM and the 20x desktop are
|
|
26
|
+
* the same client. There is no route, field or token that only one of them
|
|
27
|
+
* can use. `POST /api/coding-agents/sessions/:id/ingest` in particular is
|
|
28
|
+
* identical for all three; that is the whole payoff of the extraction.
|
|
29
|
+
*
|
|
30
|
+
* 4. TOKENS TRAVEL IN THE ENVIRONMENT OR IN HEADERS, NEVER IN ARGV.
|
|
31
|
+
* `/proc/<pid>/cmdline` is world-readable; `/proc/<pid>/environ` is readable
|
|
32
|
+
* only by the process owner. Every credential below is either an
|
|
33
|
+
* `Authorization: Bearer` header or an environment variable handed to a
|
|
34
|
+
* child process. Do not "improve" this by adding a `--token` flag.
|
|
35
|
+
*/
|
|
36
|
+
export interface HarnessOptions {
|
|
37
|
+
/** workflow-api base url, e.g. https://stage-api.peakflo.ai */
|
|
38
|
+
controlPlaneUrl: string;
|
|
39
|
+
/**
|
|
40
|
+
* Enrolment token. Single-use and short-lived. Injected as a sandbox env
|
|
41
|
+
* var at creation for managed sandboxes, or pasted by an operator for a
|
|
42
|
+
* self-hosted install. Exchanged ONCE at the register route.
|
|
43
|
+
*/
|
|
44
|
+
enrolmentToken: string;
|
|
45
|
+
/** Loopback port for the health check. Never bound to 0.0.0.0. */
|
|
46
|
+
healthPort: number;
|
|
47
|
+
/** Preferred loopback port for the sentinel-authenticated GitHub TLS proxy. */
|
|
48
|
+
githubProxyPort?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Commit and push dirty repository worktrees after each turn.
|
|
51
|
+
*
|
|
52
|
+
* Defaults on for the managed sandbox runtime. Operators can disable it,
|
|
53
|
+
* but the checkout still stays on disk until the session is released.
|
|
54
|
+
*/
|
|
55
|
+
autoCommitDirtyWorktree?: boolean;
|
|
56
|
+
/** Milliseconds between heartbeats. The control plane may override this. */
|
|
57
|
+
heartbeatIntervalMs: number;
|
|
58
|
+
/**
|
|
59
|
+
* True when pf-bootstrap could not reach the private registry and started
|
|
60
|
+
* the warm fallback copy instead. Reported at registration so the control
|
|
61
|
+
* plane can see a degraded fleet.
|
|
62
|
+
*/
|
|
63
|
+
degraded: boolean;
|
|
64
|
+
degradedReason?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Root under which every session workspace is created, one directory each.
|
|
67
|
+
*
|
|
68
|
+
* `/var/pf/workspaces` in the sandbox image, owned `pfharness` mode 0700.
|
|
69
|
+
* Decision 2 dropped the unix-user-per-session design, so this path IS the
|
|
70
|
+
* filesystem separation between two sessions of the same tenant — everything
|
|
71
|
+
* stronger is enforced by the harness and the phase-3 MCP gateway.
|
|
72
|
+
*/
|
|
73
|
+
workspaceRoot: string;
|
|
74
|
+
/**
|
|
75
|
+
* Which hosting mode this is. ADVISORY: it changes routing and what an
|
|
76
|
+
* operator sees, never a permission. The control plane already knows the
|
|
77
|
+
* truth from how the enrolment token was minted, so a runner that lied here
|
|
78
|
+
* would gain nothing.
|
|
79
|
+
*/
|
|
80
|
+
runtime: RunnerRuntime;
|
|
81
|
+
/**
|
|
82
|
+
* How many sessions this host will run at once.
|
|
83
|
+
*
|
|
84
|
+
* DEFAULTS TO 1, and that default is a security position: cross-session
|
|
85
|
+
* isolation on a shared customer VM is the customer's duty, and 1 is the only
|
|
86
|
+
* value that needs no such assumption.
|
|
87
|
+
*/
|
|
88
|
+
maxConcurrentSessions: number;
|
|
89
|
+
/** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
|
|
90
|
+
labels?: string[];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every request body in this contract carries the protocol version, so the
|
|
94
|
+
* control plane can gate on it without depending on a header surviving a
|
|
95
|
+
* proxy. The header `x-pf-protocol-version` is sent as well and must agree.
|
|
96
|
+
*/
|
|
97
|
+
export interface ProtocolEnvelope {
|
|
98
|
+
protocolVersion: number;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Anything that survives a JSON round trip. Used for `payload`, which is
|
|
102
|
+
* stored as JSONB and must therefore be exactly this and nothing wider.
|
|
103
|
+
*/
|
|
104
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
105
|
+
[key: string]: JsonValue;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* The error body every non-2xx response uses. `retryable` is advisory; the
|
|
109
|
+
* authority on whether to retry is the HTTP status (see `FINAL_HTTP_STATUSES`
|
|
110
|
+
* in `./contract.ts`), because a status survives a proxy rewriting a body.
|
|
111
|
+
*/
|
|
112
|
+
export interface ControlPlaneErrorBody {
|
|
113
|
+
error: string;
|
|
114
|
+
/** Stable machine-readable reason. See `CONTRACT_ERROR_CODES`. */
|
|
115
|
+
code?: string;
|
|
116
|
+
retryable?: boolean;
|
|
117
|
+
/** Present on a protocol refusal, so an operator can see the floor. */
|
|
118
|
+
minProtocolVersion?: number;
|
|
119
|
+
/** Present on a harness-version refusal. */
|
|
120
|
+
minHarnessVersion?: string;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Called by `pf-bootstrap`, BEFORE any harness code exists on the host, with
|
|
124
|
+
* the enrolment token as the bearer. It is the only route in this contract
|
|
125
|
+
* whose caller is not the harness.
|
|
126
|
+
*
|
|
127
|
+
* The enrolment token is valid for one BOOT WINDOW, not one HTTP call:
|
|
128
|
+
* pf-bootstrap presents it here, then hands it to the harness, which presents
|
|
129
|
+
* it again at the register route. A control plane that wants a strictly
|
|
130
|
+
* single-use token returns a FRESH one in `harnessEnrolmentToken`;
|
|
131
|
+
* pf-bootstrap already prefers that field when it is present.
|
|
132
|
+
*/
|
|
133
|
+
export interface BootstrapRequest {
|
|
134
|
+
/**
|
|
135
|
+
* OPTIONAL here, unlike every other request in this contract, and the one
|
|
136
|
+
* deliberate exception to the envelope rule.
|
|
137
|
+
*
|
|
138
|
+
* `pf-bootstrap` ships inside the Daytona snapshot and predates the runner
|
|
139
|
+
* protocol, so the deployed copies do not send it. Making it required would
|
|
140
|
+
* declare a contract that the only real client already violates. The control
|
|
141
|
+
* plane must therefore treat an absent value here as "unknown, pre-protocol
|
|
142
|
+
* bootstrap" and still answer — refusing would brick every existing sandbox
|
|
143
|
+
* image on the first deploy.
|
|
144
|
+
*
|
|
145
|
+
* This is safe precisely because bootstrap grants nothing: it hands out a
|
|
146
|
+
* pinned version, a read-only registry token and a URL. The gate that
|
|
147
|
+
* matters runs at REGISTER, where the envelope is required and enforced.
|
|
148
|
+
*/
|
|
149
|
+
protocolVersion?: number;
|
|
150
|
+
/** Best-effort machine identity. Advisory — never an authorisation input. */
|
|
151
|
+
hostname?: string;
|
|
152
|
+
platform?: string;
|
|
153
|
+
arch?: string;
|
|
154
|
+
nodeVersion?: string;
|
|
155
|
+
}
|
|
156
|
+
export interface BootstrapResponse {
|
|
157
|
+
/** EXACT version. Never a semver range — the boot must be reproducible. */
|
|
158
|
+
harnessVersion: string;
|
|
159
|
+
/** Private registry base url. Public npm is refused in pf-bootstrap. */
|
|
160
|
+
registryUrl: string;
|
|
161
|
+
/**
|
|
162
|
+
* Short-lived registry READ token. Written to a 0600 .npmrc owned by the
|
|
163
|
+
* harness user, then deleted. Must contain no control characters —
|
|
164
|
+
* pf-bootstrap refuses it otherwise, because the value is written straight
|
|
165
|
+
* into an .npmrc and a newline would inject further npm directives.
|
|
166
|
+
*/
|
|
167
|
+
registryToken: string;
|
|
168
|
+
/** sha512 of the tarball, verified before anything is executed. */
|
|
169
|
+
integritySha512: string;
|
|
170
|
+
/** Must be reachable FROM THE HOST, not from the control plane's own host. */
|
|
171
|
+
controlPlaneUrl: string;
|
|
172
|
+
/** Optional fresh enrolment token, preferred by pf-bootstrap when present. */
|
|
173
|
+
harnessEnrolmentToken?: string;
|
|
174
|
+
/** The floor, so a doomed boot can be abandoned before fetching anything. */
|
|
175
|
+
minProtocolVersion?: number;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* What this host can actually do. Reported at registration and refreshed on
|
|
179
|
+
* every heartbeat, because a host is upgraded in place: `docker` can appear
|
|
180
|
+
* and a backend can be installed without the runner ever restarting.
|
|
181
|
+
*/
|
|
182
|
+
export interface RunnerCapabilities {
|
|
183
|
+
/** `process.platform` — 'linux' | 'darwin' | 'win32' | ... */
|
|
184
|
+
os: string;
|
|
185
|
+
/** `process.arch` — 'x64' | 'arm64' | ... */
|
|
186
|
+
arch: string;
|
|
187
|
+
harnessVersion: string;
|
|
188
|
+
/**
|
|
189
|
+
* The sandbox TOOLS layer (gh, @peculiar/x509, ...) currently installed,
|
|
190
|
+
* synced by pf-bootstrap independently of the harness. Absent on
|
|
191
|
+
* self-hosted runners and desktop, which never had this layer, and on a
|
|
192
|
+
* sandbox that booted before it existed.
|
|
193
|
+
*/
|
|
194
|
+
sandboxToolsVersion?: string;
|
|
195
|
+
nodeVersion: string;
|
|
196
|
+
hostname: string;
|
|
197
|
+
/** Coding-agent backends found on this host, with the version detected. */
|
|
198
|
+
backends: RunnerBackend[];
|
|
199
|
+
/**
|
|
200
|
+
* Flat union of every backend's reported models, deduplicated by id.
|
|
201
|
+
*
|
|
202
|
+
* Redundant with `backends[].models`, but the control-plane model picker
|
|
203
|
+
* reads this form because it does not have to walk the backend list. Absent
|
|
204
|
+
* or empty means "the host could not enumerate models" — NEVER a guess.
|
|
205
|
+
*/
|
|
206
|
+
models?: RunnerModel[];
|
|
207
|
+
/** A repository-backed session is impossible without git. */
|
|
208
|
+
git: boolean;
|
|
209
|
+
/** Some backends shell out to Docker. Absent is not an error. */
|
|
210
|
+
docker: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* How many sessions this host will run at once. DEFAULTS TO 1 on a
|
|
213
|
+
* self-hosted runner: cross-session isolation on a shared customer VM is the
|
|
214
|
+
* customer's duty, and 1 is the only value that needs no such assumption.
|
|
215
|
+
*/
|
|
216
|
+
maxConcurrentSessions: number;
|
|
217
|
+
/** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
|
|
218
|
+
labels?: string[];
|
|
219
|
+
}
|
|
220
|
+
export interface RunnerBackend {
|
|
221
|
+
/** 'opencode' | 'claude-code' | 'codex' | 'acp' | ... — an open vocabulary. */
|
|
222
|
+
name: string;
|
|
223
|
+
version: string | null;
|
|
224
|
+
/**
|
|
225
|
+
* Models this backend can drive, when the host can enumerate them.
|
|
226
|
+
*
|
|
227
|
+
* Absent means the host could not say — NEVER an empty-but-meaningful list.
|
|
228
|
+
* A model list is advisory, not a validation gate: the id a lease names is
|
|
229
|
+
* what is actually called, and even a model the harness never heard of is
|
|
230
|
+
* sent through verbatim.
|
|
231
|
+
*/
|
|
232
|
+
models?: RunnerModel[];
|
|
233
|
+
}
|
|
234
|
+
/** One model a harness can drive, in the shape the control-plane UI renders. */
|
|
235
|
+
export interface RunnerModel {
|
|
236
|
+
/** Full model reference, e.g. 'peakflo/gpt-5.6-sol' or 'gpt-4.1'. */
|
|
237
|
+
id: string;
|
|
238
|
+
name: string;
|
|
239
|
+
}
|
|
240
|
+
export interface RunnerRegisterRequest extends ProtocolEnvelope {
|
|
241
|
+
capabilities: RunnerCapabilities;
|
|
242
|
+
/**
|
|
243
|
+
* Which of the three hosting modes this is. Advisory: it changes ROUTING and
|
|
244
|
+
* what an operator sees, never a permission. The control plane already knows
|
|
245
|
+
* the truth from how the enrolment token was minted.
|
|
246
|
+
*/
|
|
247
|
+
runtime: RunnerRuntime;
|
|
248
|
+
/** True when the warm fallback harness was booted instead of the pinned one. */
|
|
249
|
+
degraded: boolean;
|
|
250
|
+
degradedReason?: string;
|
|
251
|
+
}
|
|
252
|
+
export type RunnerRuntime = 'sandbox' | 'self-hosted' | 'desktop';
|
|
253
|
+
export interface RunnerRegisterResponse {
|
|
254
|
+
/**
|
|
255
|
+
* The version gate. `false` is FINAL — never retried. The harness exits
|
|
256
|
+
* non-zero so the host is recycled onto a supported version. It must EXIT
|
|
257
|
+
* and not merely throw: a listening health socket keeps the Node event loop
|
|
258
|
+
* alive for ever, so a harness that only threw would hang and the gate would
|
|
259
|
+
* never take effect.
|
|
260
|
+
*/
|
|
261
|
+
accepted: boolean;
|
|
262
|
+
/** Why, in words an operator can act on. Required when accepted is false. */
|
|
263
|
+
reason?: string;
|
|
264
|
+
code?: string;
|
|
265
|
+
minProtocolVersion?: number;
|
|
266
|
+
minHarnessVersion?: string;
|
|
267
|
+
/** Absent when accepted is false. */
|
|
268
|
+
runnerId?: string;
|
|
269
|
+
tenantId?: string;
|
|
270
|
+
/**
|
|
271
|
+
* Long-lived runner credential. Replaces the enrolment token, which burns
|
|
272
|
+
* here. MEMORY ONLY — never written to disk, never handed to a session
|
|
273
|
+
* process, never in argv.
|
|
274
|
+
*/
|
|
275
|
+
runnerCredential?: string;
|
|
276
|
+
/** ISO 8601. Rotate before this using the rotate route. */
|
|
277
|
+
credentialExpiresAt?: string;
|
|
278
|
+
/** ISO 8601. Rotate at this point, comfortably before expiry. */
|
|
279
|
+
rotateAfter?: string;
|
|
280
|
+
/** The control plane owns the cadence. */
|
|
281
|
+
heartbeatIntervalMs?: number;
|
|
282
|
+
/** Seconds the harness should ask the lease route to hold a poll open. */
|
|
283
|
+
leaseWaitSeconds?: number;
|
|
284
|
+
}
|
|
285
|
+
export type RunnerStatus = 'starting' | 'ready' | 'busy' | 'draining';
|
|
286
|
+
export interface RunnerHeartbeatRequest extends ProtocolEnvelope {
|
|
287
|
+
status: RunnerStatus;
|
|
288
|
+
uptimeMs: number;
|
|
289
|
+
activeSessions: number;
|
|
290
|
+
/** Session ids currently held, so the control plane can spot a split brain. */
|
|
291
|
+
activeSessionIds?: string[];
|
|
292
|
+
degraded: boolean;
|
|
293
|
+
/**
|
|
294
|
+
* Capability REFRESH. A host is upgraded in place, so the control plane must
|
|
295
|
+
* never treat the registration-time snapshot as permanent.
|
|
296
|
+
*/
|
|
297
|
+
capabilities: RunnerCapabilities;
|
|
298
|
+
}
|
|
299
|
+
export interface RunnerHeartbeatResponse {
|
|
300
|
+
ok: boolean;
|
|
301
|
+
/** Exact harness pin currently selected by the control plane. */
|
|
302
|
+
harnessVersion?: string;
|
|
303
|
+
/**
|
|
304
|
+
* Exact sandbox-tools pin, when the control plane has the tools layer
|
|
305
|
+
* turned on. Additive and independent of harnessVersion — either can
|
|
306
|
+
* change without the other.
|
|
307
|
+
*/
|
|
308
|
+
sandboxToolsVersion?: string;
|
|
309
|
+
/**
|
|
310
|
+
* 'drain' asks the harness to finish its work and take no more.
|
|
311
|
+
* 'restart' asks it to exit so the host is recycled.
|
|
312
|
+
*/
|
|
313
|
+
action?: 'none' | 'drain' | 'restart';
|
|
314
|
+
heartbeatIntervalMs?: number;
|
|
315
|
+
/**
|
|
316
|
+
* The outbound-only answer to "how does anything reach a running session?".
|
|
317
|
+
* The control plane cannot dial in, so an abort or a user's answer to a
|
|
318
|
+
* question rides back on the next heartbeat. Commands are idempotent and may
|
|
319
|
+
* be repeated until the harness acknowledges them by their effect.
|
|
320
|
+
*/
|
|
321
|
+
sessionCommands?: SessionCommand[];
|
|
322
|
+
}
|
|
323
|
+
export interface SessionCommand {
|
|
324
|
+
sessionId: string;
|
|
325
|
+
/**
|
|
326
|
+
* 'abort' stops the turn; 'answer' resolves a pending question part;
|
|
327
|
+
* 'release-workspace' removes a checkout after the session ends.
|
|
328
|
+
*/
|
|
329
|
+
command: 'abort' | 'answer' | 'release-workspace';
|
|
330
|
+
/** Set when command is 'answer' — the partId of the question being answered. */
|
|
331
|
+
partId?: string;
|
|
332
|
+
/** Set when command is 'answer' — the user's reply. */
|
|
333
|
+
answer?: string;
|
|
334
|
+
/**
|
|
335
|
+
* Set when command is 'answer' AND the user picked a NAMED OPTION rather
|
|
336
|
+
* than typing free text.
|
|
337
|
+
*
|
|
338
|
+
* ADDITIVE AND OPTIONAL, so it does not break protocol version 1: a harness
|
|
339
|
+
* built before this field existed ignores an unknown key and continues to use
|
|
340
|
+
* `answer`, which still carries the human-readable text. A harness that reads
|
|
341
|
+
* it can branch on the exact option instead of matching a display label by
|
|
342
|
+
* string — labels are ambiguous, translatable and repeatable between
|
|
343
|
+
* questions, and ids are none of those things.
|
|
344
|
+
*/
|
|
345
|
+
optionId?: string;
|
|
346
|
+
/** De-duplication key. The same commandId must be applied at most once. */
|
|
347
|
+
commandId: string;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* The bounded long-poll that replaces a socket.
|
|
351
|
+
*
|
|
352
|
+
* 204 No Content no work; poll again immediately
|
|
353
|
+
* 200 a lease, in the body below
|
|
354
|
+
*
|
|
355
|
+
* `wait` is in SECONDS and defaults to 25. 25 matches the existing
|
|
356
|
+
* `boundSseStream` heartbeat, and it is chosen against Cloud Run: a request
|
|
357
|
+
* slot is billed for the whole life of a connection and capped at 3600s, which
|
|
358
|
+
* is what rules out one persistent WebSocket per runner.
|
|
359
|
+
*/
|
|
360
|
+
export interface LeaseResponse {
|
|
361
|
+
/**
|
|
362
|
+
* Exact harness pin currently selected by the control plane.
|
|
363
|
+
*
|
|
364
|
+
* This is additive. A runner that already received a live lease finishes it
|
|
365
|
+
* before it acts on a changed pin. Idle runners also receive the pin on the
|
|
366
|
+
* heartbeat response, because a 204 lease response has no body by contract.
|
|
367
|
+
*/
|
|
368
|
+
harnessVersion?: string;
|
|
369
|
+
/**
|
|
370
|
+
* Exact sandbox-tools pin (gh, @peculiar/x509, ...), independent of
|
|
371
|
+
* harnessVersion. Same additivity rule: idle runners get it on the
|
|
372
|
+
* heartbeat response too, since a 204 lease has no body.
|
|
373
|
+
*/
|
|
374
|
+
sandboxToolsVersion?: string;
|
|
375
|
+
session: LeasedSession;
|
|
376
|
+
leaseId: string;
|
|
377
|
+
/**
|
|
378
|
+
* ISO 8601 visibility timeout. Renew before this or the watchdog requeues
|
|
379
|
+
* the session onto another runner. Every successful ingest also extends it,
|
|
380
|
+
* so a runner that is producing output rarely needs to call renew at all.
|
|
381
|
+
*/
|
|
382
|
+
leaseExpiresAt: string;
|
|
383
|
+
/**
|
|
384
|
+
* Session-scoped MCP token. Bound to runnerId AND sessionId, short TTL,
|
|
385
|
+
* revoked when the session ends. It carries NO tool list — the allowlist is
|
|
386
|
+
* re-read from the database on every call so revocation is immediate.
|
|
387
|
+
*
|
|
388
|
+
* NO VENDOR CREDENTIAL EVER LEAVES THE CONTROL PLANE. This token buys access
|
|
389
|
+
* to the gateway, and the gateway injects the real credentials server-side.
|
|
390
|
+
*/
|
|
391
|
+
mcpToken: string;
|
|
392
|
+
/**
|
|
393
|
+
* Per-session, budget-capped model token. NOT the tenant's model key — a
|
|
394
|
+
* customer VM is outside our security perimeter and must never hold one.
|
|
395
|
+
*/
|
|
396
|
+
modelToken: string;
|
|
397
|
+
/**
|
|
398
|
+
* ISO 8601 expiry of `modelToken`, when the control plane minted one with a
|
|
399
|
+
* known lifetime (a `self-hosted`/`desktop` per-turn key; absent for a
|
|
400
|
+
* sandbox's longer-lived per-SANDBOX key, which is not refreshed this way).
|
|
401
|
+
* Echoed back on `/renew` as `modelTokenExpiresAt` so the control plane can
|
|
402
|
+
* re-mint it near expiry, on the same cadence the GitHub credential already
|
|
403
|
+
* uses — a fixed 1h key TTL would otherwise cut a healthy turn on these
|
|
404
|
+
* runtimes on its own, independent of `maxTurnDurationMs`.
|
|
405
|
+
*/
|
|
406
|
+
modelTokenExpiresAt?: string;
|
|
407
|
+
/** In-memory only. It is never passed through a process environment. */
|
|
408
|
+
githubCredential?: LeasedGitHubCredential;
|
|
409
|
+
}
|
|
410
|
+
export interface LeasedSession {
|
|
411
|
+
sessionId: string;
|
|
412
|
+
tenantId: string;
|
|
413
|
+
/** One lease is one TURN. A new turn on the same session is a new lease. */
|
|
414
|
+
turnId: string;
|
|
415
|
+
runtime: RunnerRuntime;
|
|
416
|
+
agent: LeasedAgent;
|
|
417
|
+
input: LeasedTurnInput;
|
|
418
|
+
/**
|
|
419
|
+
* The harness keeps a local SQLite store so it survives its own crash. When
|
|
420
|
+
* this is non-null the harness already holds transcript up to this rev and
|
|
421
|
+
* need not replay it. Null means start cold.
|
|
422
|
+
*/
|
|
423
|
+
resumeFromRev: number | null;
|
|
424
|
+
/**
|
|
425
|
+
* The backend's own handle for this conversation, when the control plane has
|
|
426
|
+
* one recorded.
|
|
427
|
+
*
|
|
428
|
+
* AN OPAQUE CHECKPOINT. The control plane never interprets it; it stores what
|
|
429
|
+
* the last runner reported and hands it back, so a runner that still has that
|
|
430
|
+
* backend session alive can RESUME the conversation instead of rebuilding it
|
|
431
|
+
* from a log. Null means no backend session is known and the runner must
|
|
432
|
+
* replay history into a fresh one.
|
|
433
|
+
*/
|
|
434
|
+
backendSessionId?: string | null;
|
|
435
|
+
/**
|
|
436
|
+
* Which backend minted `backendSessionId`.
|
|
437
|
+
*
|
|
438
|
+
* Without this the cross-backend safety check below could only ever run on a
|
|
439
|
+
* runner that happened to have run the earlier turn itself — that is, never
|
|
440
|
+
* in the case it exists for.
|
|
441
|
+
*/
|
|
442
|
+
backendName?: string | null;
|
|
443
|
+
/**
|
|
444
|
+
* WHICH BACKEND INSTANCE minted `backendSessionId` — see `TurnMetadata`.
|
|
445
|
+
*
|
|
446
|
+
* A runner resumes only when this equals its OWN backend instance id. Any
|
|
447
|
+
* other value, including absent, means the handle belongs to somebody else's
|
|
448
|
+
* opencode state directory and is not a session at all from here.
|
|
449
|
+
*/
|
|
450
|
+
backendOwnerId?: string | null;
|
|
451
|
+
workspace: LeasedWorkspace;
|
|
452
|
+
limits: LeasedLimits;
|
|
453
|
+
}
|
|
454
|
+
export interface LeasedAgent {
|
|
455
|
+
agentId: string;
|
|
456
|
+
name: string;
|
|
457
|
+
/** 'opencode' first. An open vocabulary, not a union. */
|
|
458
|
+
backend: string;
|
|
459
|
+
systemPrompt: string | null;
|
|
460
|
+
model: LeasedModel;
|
|
461
|
+
/**
|
|
462
|
+
* The real permission mode, honoured for real. Phase 0 recorded G1: the
|
|
463
|
+
* 20x claude-code adapter hardcodes 'bypassPermissions'. This field exists so
|
|
464
|
+
* that stops being possible.
|
|
465
|
+
*/
|
|
466
|
+
permissionMode: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions';
|
|
467
|
+
/** Always the skill's PUBLISHED content, never its draft. */
|
|
468
|
+
skills: LeasedSkill[];
|
|
469
|
+
mcpServers: LeasedMcpServer[];
|
|
470
|
+
}
|
|
471
|
+
export interface LeasedModel {
|
|
472
|
+
provider: string;
|
|
473
|
+
model: string;
|
|
474
|
+
/**
|
|
475
|
+
* The model gateway base url. The harness talks to this with `modelToken`,
|
|
476
|
+
* never directly to a vendor with a tenant key.
|
|
477
|
+
*/
|
|
478
|
+
gatewayUrl: string;
|
|
479
|
+
}
|
|
480
|
+
export interface LeasedSkill {
|
|
481
|
+
skillId: string;
|
|
482
|
+
name: string;
|
|
483
|
+
/** From skills.published_content. A draft must never reach a runner. */
|
|
484
|
+
content: string;
|
|
485
|
+
}
|
|
486
|
+
export interface LeasedMcpServer {
|
|
487
|
+
serverId: string;
|
|
488
|
+
name: string;
|
|
489
|
+
/**
|
|
490
|
+
* Always 'http'. A 'local' (stdio) server is refused for a cloud sandbox and
|
|
491
|
+
* is never expressible here, because a stdio server would mean a credential
|
|
492
|
+
* on the host.
|
|
493
|
+
*/
|
|
494
|
+
transport: 'http';
|
|
495
|
+
/** /api/mcp/agent/:sessionId/:serverId — authenticated with `mcpToken`. */
|
|
496
|
+
url: string;
|
|
497
|
+
/**
|
|
498
|
+
* ADVISORY ONLY, for generating AGENTS.md and for what the UI shows.
|
|
499
|
+
* THE GATEWAY IS AUTHORITATIVE: `tools/list` returns only allowed tools and
|
|
500
|
+
* `tools/call` refuses anything outside the allowlist before dispatch. A
|
|
501
|
+
* harness must never be trusted to enforce this — that is exactly defect G2.
|
|
502
|
+
* `null` means "every tool the server exposes".
|
|
503
|
+
*/
|
|
504
|
+
allowedTools: string[] | null;
|
|
505
|
+
}
|
|
506
|
+
export interface LeasedTurnInput {
|
|
507
|
+
/** 'message' is a new user turn; 'resume' continues an interrupted one. */
|
|
508
|
+
kind: 'message' | 'resume';
|
|
509
|
+
role: 'user';
|
|
510
|
+
content: string;
|
|
511
|
+
attachments?: LeasedAttachment[];
|
|
512
|
+
/**
|
|
513
|
+
* Attachments the control plane could NOT turn into a `LeasedAttachment`
|
|
514
|
+
* at lease time — a local-storage `local://` URL the harness cannot fetch,
|
|
515
|
+
* or a signing failure. Without this the harness has no way to know these
|
|
516
|
+
* ever existed: they are absent from `attachments`, so its own download
|
|
517
|
+
* loop never runs for them and never adds them to its OWN failure list
|
|
518
|
+
* either. `session-runner.ts` merges these into that same list before
|
|
519
|
+
* building the prompt note, so the model (and the user, via the
|
|
520
|
+
* transcript) is told a file it was named is not available, instead of
|
|
521
|
+
* the turn silently running as if it had never been attached at all.
|
|
522
|
+
*/
|
|
523
|
+
droppedAttachments?: Array<{
|
|
524
|
+
name: string;
|
|
525
|
+
reason: string;
|
|
526
|
+
}>;
|
|
527
|
+
}
|
|
528
|
+
export interface LeasedAttachment {
|
|
529
|
+
name: string;
|
|
530
|
+
mimeType: string;
|
|
531
|
+
/** Pre-signed, short-lived. The harness fetches it; it is not inlined. */
|
|
532
|
+
url: string;
|
|
533
|
+
sizeBytes?: number;
|
|
534
|
+
}
|
|
535
|
+
export interface LeasedWorkspace {
|
|
536
|
+
/**
|
|
537
|
+
* 'scratch' is a bare working directory and is the default — the product
|
|
538
|
+
* requirement is an agent with MCP access, not filesystem access.
|
|
539
|
+
* 'repository' is an explicit per-agent opt-in and only ever on a runner
|
|
540
|
+
* the customer chose.
|
|
541
|
+
*/
|
|
542
|
+
mode: 'scratch' | 'repository';
|
|
543
|
+
repositoryUrl?: string;
|
|
544
|
+
ref?: string;
|
|
545
|
+
repositories?: LeasedGitHubRepository[];
|
|
546
|
+
/** Names requested by the session, retained even when the server refuses them. */
|
|
547
|
+
requestedRepositories?: string[];
|
|
548
|
+
/** A server-side ruleset or installation refusal. The harness fails loudly. */
|
|
549
|
+
refusalReason?: string;
|
|
550
|
+
}
|
|
551
|
+
export interface LeasedGitHubRepository {
|
|
552
|
+
id: number;
|
|
553
|
+
fullName: string;
|
|
554
|
+
defaultBranch: string;
|
|
555
|
+
cloneUrl: string;
|
|
556
|
+
}
|
|
557
|
+
export interface LeasedGitHubCredential {
|
|
558
|
+
token: string;
|
|
559
|
+
expiresAt: string;
|
|
560
|
+
repositories: LeasedGitHubRepository[];
|
|
561
|
+
}
|
|
562
|
+
export interface LeasedLimits {
|
|
563
|
+
/**
|
|
564
|
+
* Last-line wall-clock ceiling on the turn, or `null` for NO ceiling.
|
|
565
|
+
*
|
|
566
|
+
* THIS IS NOT THE PRIMARY GUARD ANY MORE. A runaway model is caught by
|
|
567
|
+
* `maxTurnIdleMs` below — the wall clock stayed the enforcement instrument
|
|
568
|
+
* for too long, and a real coding turn routinely runs 30-90 minutes, so a
|
|
569
|
+
* fixed cap cut healthy turns as often as it caught broken ones.
|
|
570
|
+
*
|
|
571
|
+
* `null` is the default: no operator has opted into a ceiling, so none is
|
|
572
|
+
* enforced. Set only when `CODING_AGENT_MAX_TURN_MINUTES` is configured
|
|
573
|
+
* (services/coding-agents/config.ts), in which case it is a real backstop —
|
|
574
|
+
* the watchdog requeues a session whose lease outlives it, but a healthy
|
|
575
|
+
* runner should never reach it, because the idle guard below stops a hung
|
|
576
|
+
* turn first.
|
|
577
|
+
*/
|
|
578
|
+
maxTurnDurationMs: number | null;
|
|
579
|
+
/**
|
|
580
|
+
* No progress — a new message part, a tool call start/end, or streamed
|
|
581
|
+
* text — for this long ends the turn: the backend is hung, not merely slow.
|
|
582
|
+
* A single tool call that legitimately runs long (a big test suite, a slow
|
|
583
|
+
* build) produces nothing until it COMPLETES, so this has to be generous
|
|
584
|
+
* enough to outlast one, not just a network hiccup. Approval wait time is
|
|
585
|
+
* excluded, exactly like `maxTurnDurationMs`. Defaults to 20 minutes in the
|
|
586
|
+
* runner when absent; configurable via `CODING_AGENT_MAX_TURN_IDLE_MINUTES`.
|
|
587
|
+
*/
|
|
588
|
+
maxTurnIdleMs?: number;
|
|
589
|
+
maxOutputTokens?: number;
|
|
590
|
+
/** Per-session spend ceiling in USD. */
|
|
591
|
+
spendCapUsd?: number;
|
|
592
|
+
}
|
|
593
|
+
export interface LeaseRenewRequest extends ProtocolEnvelope {
|
|
594
|
+
/** Advisory progress note, so an operator can see a slow turn is alive. */
|
|
595
|
+
status?: RunnerStatus;
|
|
596
|
+
/** The server returns a replacement only when this enters its safety window. */
|
|
597
|
+
githubTokenExpiresAt?: string;
|
|
598
|
+
/** Same idea, for the per-turn LiteLLM key. See `LeaseResponse.modelTokenExpiresAt`. */
|
|
599
|
+
modelTokenExpiresAt?: string;
|
|
600
|
+
}
|
|
601
|
+
export interface LeaseRenewResponse {
|
|
602
|
+
/**
|
|
603
|
+
* False when the lease has already expired and been requeued elsewhere. The
|
|
604
|
+
* harness must then ABANDON the turn and stop ingesting for it: another
|
|
605
|
+
* runner owns the session now, and two writers would interleave a transcript.
|
|
606
|
+
*/
|
|
607
|
+
renewed: boolean;
|
|
608
|
+
leaseExpiresAt?: string;
|
|
609
|
+
reason?: string;
|
|
610
|
+
githubCredential?: LeasedGitHubCredential;
|
|
611
|
+
/**
|
|
612
|
+
* A replacement per-turn model token, present only when
|
|
613
|
+
* `modelTokenExpiresAt` on the request was inside the refresh margin AND
|
|
614
|
+
* this runner is not a sandbox (whose key is not refreshed this way).
|
|
615
|
+
*/
|
|
616
|
+
modelToken?: string;
|
|
617
|
+
/** Expiry of the token above, when one was minted. */
|
|
618
|
+
modelTokenExpiresAt?: string;
|
|
619
|
+
/**
|
|
620
|
+
* Commands queued for this runner, delivered on the renewal.
|
|
621
|
+
*
|
|
622
|
+
* WHY HERE AND NOT ONLY ON THE HEARTBEAT. The heartbeat is 30 s. That is fine
|
|
623
|
+
* for an abort and far too slow for a PERMISSION PROMPT, where the user
|
|
624
|
+
* clicks Allow and then watches an unchanged screen. Renew is the only
|
|
625
|
+
* control-plane call that runs for the whole duration of a turn — which is
|
|
626
|
+
* exactly the window an approval lives in — and while a prompt is open the
|
|
627
|
+
* harness tightens it to `APPROVAL_RENEW_INTERVAL_MS`.
|
|
628
|
+
*
|
|
629
|
+
* ADDITIVE AND OPTIONAL, so protocol v1 is unaffected: a harness built before
|
|
630
|
+
* this field existed ignores an unknown key and keeps collecting commands on
|
|
631
|
+
* the heartbeat alone, exactly as it did.
|
|
632
|
+
*
|
|
633
|
+
* Only present when the lease was actually renewed. A runner that has lost
|
|
634
|
+
* its lease must not be handed a command for a turn another runner now owns.
|
|
635
|
+
*/
|
|
636
|
+
sessionCommands?: SessionCommand[];
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* THE RUNTIME-NEUTRAL ROUTE. A Daytona sandbox, a customer VM and the 20x
|
|
640
|
+
* desktop all call exactly this, with exactly this body. Nothing in it names a
|
|
641
|
+
* hosting mode. If a future change makes one runtime special here, the change
|
|
642
|
+
* is wrong.
|
|
643
|
+
*/
|
|
644
|
+
export interface IngestRequest extends ProtocolEnvelope {
|
|
645
|
+
/** Proves the caller still owns the session. Parts without a live lease are refused. */
|
|
646
|
+
leaseId: string;
|
|
647
|
+
parts: TranscriptPartInput[];
|
|
648
|
+
/**
|
|
649
|
+
* The EXACT MODEL-FACING messages this turn produced, appended to the
|
|
650
|
+
* session's durable conversation log.
|
|
651
|
+
*
|
|
652
|
+
* Sent BESIDE `parts`, never derived from them. `parts` is a UI projection —
|
|
653
|
+
* ephemeral markers dropped, tool calls flattened for display, streaming
|
|
654
|
+
* deltas overwritten in place. That shape is right for a screen and lossy for
|
|
655
|
+
* a prompt, and replaying it at a model is guessing at what the model was
|
|
656
|
+
* told. `seq` is absent here for the same reason it is absent from
|
|
657
|
+
* `TranscriptPartInput`: the server allocates the ordering.
|
|
658
|
+
*/
|
|
659
|
+
modelMessages?: ModelMessageInput[];
|
|
660
|
+
/** Durable step metadata. Closes G7 — the ephemeral parts carry none. */
|
|
661
|
+
turn?: TurnMetadata;
|
|
662
|
+
/** True on the last ingest of the turn. Releases the lease. */
|
|
663
|
+
final?: boolean;
|
|
664
|
+
}
|
|
665
|
+
/** One model-facing message on the wire. Order is allocated server-side. */
|
|
666
|
+
export interface ModelMessageInput {
|
|
667
|
+
role: 'user' | 'assistant';
|
|
668
|
+
content: string;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* What `GET /api/runners/sessions/:sessionId/history` answers.
|
|
672
|
+
*
|
|
673
|
+
* THE RUNNER'S HISTORY READ PATH. Before this existed the harness could only
|
|
674
|
+
* ever WRITE the conversation out; there was no way to read it back, which is
|
|
675
|
+
* why a runner that had not personally run the earlier turns had no way to know
|
|
676
|
+
* what they were.
|
|
677
|
+
*/
|
|
678
|
+
export interface SessionHistoryResponse {
|
|
679
|
+
sessionId: string;
|
|
680
|
+
/** The backend checkpoint, when the control plane holds one. */
|
|
681
|
+
backendSessionId: string | null;
|
|
682
|
+
/** Which backend INSTANCE minted it — see `TurnMetadata.backendOwnerId`. */
|
|
683
|
+
backendOwnerId?: string | null;
|
|
684
|
+
/** The whole model-facing log, ordered by `seq` ascending. */
|
|
685
|
+
messages: Array<{
|
|
686
|
+
seq: number;
|
|
687
|
+
role: 'user' | 'assistant';
|
|
688
|
+
content: string;
|
|
689
|
+
}>;
|
|
690
|
+
}
|
|
691
|
+
export interface IngestResponse {
|
|
692
|
+
/** How many parts were written or updated. */
|
|
693
|
+
accepted: number;
|
|
694
|
+
/**
|
|
695
|
+
* Parts the control plane declined, with a reason each. A rejection is NOT
|
|
696
|
+
* an error: an ephemeral part is dropped on purpose and the harness should
|
|
697
|
+
* not retry it.
|
|
698
|
+
*/
|
|
699
|
+
rejected: IngestRejection[];
|
|
700
|
+
/**
|
|
701
|
+
* The session's highest rev after this call. This is the client cursor —
|
|
702
|
+
* the browser passes it back as `sinceRev`.
|
|
703
|
+
*/
|
|
704
|
+
maxRev: number;
|
|
705
|
+
/**
|
|
706
|
+
* Piggy-backed lease renewal. A runner that is producing output therefore
|
|
707
|
+
* never needs a separate renew call, which removes a whole class of
|
|
708
|
+
* "the lease expired while it was working" bug.
|
|
709
|
+
*/
|
|
710
|
+
leaseExpiresAt: string;
|
|
711
|
+
}
|
|
712
|
+
export interface IngestRejection {
|
|
713
|
+
partId: string;
|
|
714
|
+
reason: IngestRejectionReason;
|
|
715
|
+
detail?: string;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* PER-PART rejection reasons. Every one of these is reachable, and the stub
|
|
719
|
+
* emits each.
|
|
720
|
+
*
|
|
721
|
+
* Ownership is deliberately NOT in this list: a lease that does not own the
|
|
722
|
+
* session fails the WHOLE request with 409, because a batch half-written by a
|
|
723
|
+
* runner that no longer owns the session is worse than one not written at all.
|
|
724
|
+
*/
|
|
725
|
+
export type IngestRejectionReason =
|
|
726
|
+
/** partType is one of the three ephemeral types. Dropped on purpose. */
|
|
727
|
+
'ephemeral'
|
|
728
|
+
/** No content and no payload. Dropped on purpose. */
|
|
729
|
+
| 'empty'
|
|
730
|
+
/** Missing partId, bad role, unparseable createdAt, ... */
|
|
731
|
+
| 'malformed'
|
|
732
|
+
/**
|
|
733
|
+
* Over `MAX_INGEST_PART_BYTES`. Rejected individually, so one enormous tool
|
|
734
|
+
* result cannot take the rest of the batch down with it.
|
|
735
|
+
*/
|
|
736
|
+
| 'too-large';
|
|
737
|
+
/**
|
|
738
|
+
* THE TRANSCRIPT PART — the shape is not negotiable.
|
|
739
|
+
*
|
|
740
|
+
* It mirrors the 20x projection field for field (20x/src/main/database.ts:1225)
|
|
741
|
+
* so that a cloud session can be written straight into the desktop's local
|
|
742
|
+
* `transcript_parts` and THE 20x TRANSCRIPT UI NEEDS NO CHANGE. Adding a field
|
|
743
|
+
* here without adding it there breaks that, which is the entire reason cloud
|
|
744
|
+
* agents in 20x are cheap.
|
|
745
|
+
*
|
|
746
|
+
* This is the READ shape. `seq` and `rev` are allocated by the control plane
|
|
747
|
+
* and appear on the way out; see `TranscriptPartInput` for what goes in.
|
|
748
|
+
*/
|
|
749
|
+
export interface TranscriptPart {
|
|
750
|
+
partId: string;
|
|
751
|
+
/**
|
|
752
|
+
* SERVER-ASSIGNED. New parts take MAX(seq)+1; an existing part KEEPS its seq
|
|
753
|
+
* and only its content is replaced. That is precisely how token streaming
|
|
754
|
+
* works — a growing part must not jump to the end of the transcript on every
|
|
755
|
+
* delta.
|
|
756
|
+
*/
|
|
757
|
+
seq: number;
|
|
758
|
+
role: 'user' | 'assistant' | 'system';
|
|
759
|
+
content: string;
|
|
760
|
+
/**
|
|
761
|
+
* A PLAIN STRING, deliberately not a union.
|
|
762
|
+
*
|
|
763
|
+
* A new part type must be addable by the harness without a control-plane
|
|
764
|
+
* deploy, and an unknown type must render as text rather than crash a reader.
|
|
765
|
+
* `PERSISTED_PART_TYPES` in ./contract.ts documents the vocabulary in use;
|
|
766
|
+
* it is not a closed set.
|
|
767
|
+
*
|
|
768
|
+
* The three EPHEMERAL types — 'step-start', 'step-finish', 'system-status' —
|
|
769
|
+
* must NEVER be persisted or ingested. They are UI-only transitions with no
|
|
770
|
+
* durable meaning; `TurnMetadata` is where their information actually lives.
|
|
771
|
+
*/
|
|
772
|
+
partType: string;
|
|
773
|
+
/** Tool name for a tool part. */
|
|
774
|
+
tool: string | null;
|
|
775
|
+
/**
|
|
776
|
+
* Structured metadata: tool arguments, results, question options, ...
|
|
777
|
+
*
|
|
778
|
+
* Typed `JsonValue | null` rather than `unknown | null`, which TypeScript
|
|
779
|
+
* collapses straight back to `unknown` — the `| null` would convey nothing
|
|
780
|
+
* and the COALESCE distinction below would have no type-level meaning at all.
|
|
781
|
+
*/
|
|
782
|
+
payload: JsonValue | null;
|
|
783
|
+
/** ISO 8601. NEVER overwritten on conflict — the first write wins. */
|
|
784
|
+
createdAt: string;
|
|
785
|
+
/**
|
|
786
|
+
* SERVER-ASSIGNED, globally monotonic, bumped on UPDATES as well as inserts.
|
|
787
|
+
* Allocated from a dedicated sequence, never from SELECT MAX(rev), which is
|
|
788
|
+
* unsafe under Postgres concurrency.
|
|
789
|
+
*/
|
|
790
|
+
rev: number;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* What the harness sends. `seq` and `rev` are absent by construction, so a
|
|
794
|
+
* runner cannot fabricate an ordering and cannot replay an old rev over a
|
|
795
|
+
* newer one.
|
|
796
|
+
*/
|
|
797
|
+
export interface TranscriptPartInput {
|
|
798
|
+
/** Idempotency key. The upsert is on (sessionId, partId). */
|
|
799
|
+
partId: string;
|
|
800
|
+
role: 'user' | 'assistant' | 'system';
|
|
801
|
+
content: string;
|
|
802
|
+
partType: string;
|
|
803
|
+
/**
|
|
804
|
+
* `undefined` means "do not change" and is COALESCEd against the stored
|
|
805
|
+
* value, so a partial update can never erase metadata a fuller earlier
|
|
806
|
+
* update established. `null` means "explicitly clear".
|
|
807
|
+
*
|
|
808
|
+
* That distinction is the whole of upsert invariant 3, so both fields are
|
|
809
|
+
* typed to carry it: `?` for absent, an explicit `| null` for cleared.
|
|
810
|
+
*/
|
|
811
|
+
tool?: string | null;
|
|
812
|
+
payload?: JsonValue | null;
|
|
813
|
+
/** ISO 8601. Honoured on INSERT only; a later update never moves it. */
|
|
814
|
+
createdAt: string;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Durable step metadata. The ephemeral parts that carry duration and token
|
|
818
|
+
* counts in 20x are never persisted, so without this the cost panel has no
|
|
819
|
+
* source. That is defect G7.
|
|
820
|
+
*/
|
|
821
|
+
export interface TurnMetadata {
|
|
822
|
+
turnId: string;
|
|
823
|
+
startedAt: string;
|
|
824
|
+
finishedAt?: string;
|
|
825
|
+
model?: string;
|
|
826
|
+
inputTokens?: number;
|
|
827
|
+
outputTokens?: number;
|
|
828
|
+
cacheReadTokens?: number;
|
|
829
|
+
cacheWriteTokens?: number;
|
|
830
|
+
costUsd?: number;
|
|
831
|
+
/** 'completed' | 'aborted' | 'error' | 'turn_time_limit' | 'no_progress' */
|
|
832
|
+
stopReason?: string;
|
|
833
|
+
error?: string;
|
|
834
|
+
/** Checkout outcome for each repository requested by this session. */
|
|
835
|
+
repositories?: Array<{
|
|
836
|
+
fullName: string;
|
|
837
|
+
status: 'checkedOut' | 'refused' | 'failed';
|
|
838
|
+
reason?: string;
|
|
839
|
+
}>;
|
|
840
|
+
/**
|
|
841
|
+
* The backend session this turn ran on, reported so the NEXT turn can resume
|
|
842
|
+
* it instead of starting the model cold.
|
|
843
|
+
*
|
|
844
|
+
* Carried on the turn metadata rather than on a route of its own precisely
|
|
845
|
+
* because the final ingest is already the one message that is guaranteed to
|
|
846
|
+
* be sent — it is what releases the lease, and it is sent even when the turn
|
|
847
|
+
* failed. A checkpoint that travelled on a separate call would be lost in
|
|
848
|
+
* exactly the cases where losing it hurts most.
|
|
849
|
+
*/
|
|
850
|
+
backendSessionId?: string | null;
|
|
851
|
+
/**
|
|
852
|
+
* WHICH backend minted `backendSessionId`.
|
|
853
|
+
*
|
|
854
|
+
* Sent because a checkpoint is only meaningful to the backend that created
|
|
855
|
+
* it — an OpenCode session id handed to Claude Code is not a session, it is a
|
|
856
|
+
* string. Without this the control plane stores a handle it cannot describe,
|
|
857
|
+
* and a runner receiving it on a cold start has no way to know whether
|
|
858
|
+
* resuming it is even sensible.
|
|
859
|
+
*/
|
|
860
|
+
backendName?: string | null;
|
|
861
|
+
/**
|
|
862
|
+
* WHICH BACKEND INSTANCE minted `backendSessionId`.
|
|
863
|
+
*
|
|
864
|
+
* `backendName` says "opencode". It does not say WHICH opencode, and that is
|
|
865
|
+
* the difference between a checkpoint that works and one that has never once
|
|
866
|
+
* worked.
|
|
867
|
+
*
|
|
868
|
+
* An opencode session id is a key into ONE opencode state directory. Two
|
|
869
|
+
* runners on two machines both run "opencode", so `backendName` matches, and
|
|
870
|
+
* the second is then handed a `ses_...` its own server never issued. It
|
|
871
|
+
* answers "Session not found", the runner logs "could not resume", and the
|
|
872
|
+
* whole conversation is replayed and re-billed. Measured on the owner's
|
|
873
|
+
* machine: a conversation leased alternately to a laptop and to a
|
|
874
|
+
* cloud-sandbox runner did this on EVERY turn from the moment the second
|
|
875
|
+
* runner joined, and never once succeeded.
|
|
876
|
+
*
|
|
877
|
+
* Absent means "the runner did not say", which is treated as "not mine" —
|
|
878
|
+
* the fail-safe direction, because replaying once is recoverable and
|
|
879
|
+
* resuming someone else's handle is not.
|
|
880
|
+
*/
|
|
881
|
+
backendOwnerId?: string | null;
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* Authenticated with the CURRENT runner credential. The old credential stays
|
|
885
|
+
* valid for a short grace window, so a rotation that races a request in flight
|
|
886
|
+
* does not knock the runner offline.
|
|
887
|
+
*/
|
|
888
|
+
export interface CredentialRotateRequest extends ProtocolEnvelope {
|
|
889
|
+
harnessVersion: string;
|
|
890
|
+
}
|
|
891
|
+
export interface CredentialRotateResponse {
|
|
892
|
+
runnerCredential: string;
|
|
893
|
+
credentialExpiresAt: string;
|
|
894
|
+
rotateAfter: string;
|
|
895
|
+
/** How long the previous credential keeps working. */
|
|
896
|
+
previousCredentialValidForMs: number;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* A polite goodbye. IDEMPOTENT: deregistering an already-deregistered runner
|
|
900
|
+
* answers 200, because the alternative is a shutdown path that can fail.
|
|
901
|
+
*
|
|
902
|
+
* The control plane must not depend on this ever arriving — a runner is killed
|
|
903
|
+
* far more often than it is asked to leave. Liveness is the 30s heartbeat:
|
|
904
|
+
* offline after three missed beats, in-flight leases requeued.
|
|
905
|
+
*/
|
|
906
|
+
export interface DeregisterRequest extends ProtocolEnvelope {
|
|
907
|
+
reason?: string;
|
|
908
|
+
/** True when sessions were still running. The control plane requeues them. */
|
|
909
|
+
hadActiveSessions?: boolean;
|
|
910
|
+
}
|
|
911
|
+
export interface DeregisterResponse {
|
|
912
|
+
ok: boolean;
|
|
913
|
+
/** How many in-flight leases were requeued as a result. */
|
|
914
|
+
requeuedLeases?: number;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Served on 127.0.0.1 only, for a container probe. It is not an inbound
|
|
918
|
+
* control channel and it carries NO token: property 2 above still holds.
|
|
919
|
+
*/
|
|
920
|
+
export interface HealthReport {
|
|
921
|
+
/**
|
|
922
|
+
* Deliberately NARROWER than `RunnerStatus`: it omits 'busy'. A probe asks
|
|
923
|
+
* "may this host be used at all", and a host mid-turn is still healthy.
|
|
924
|
+
* Occupancy is `activeSessions`, and it is the control plane's business, not
|
|
925
|
+
* a container probe's.
|
|
926
|
+
*/
|
|
927
|
+
status: 'starting' | 'ready' | 'draining';
|
|
928
|
+
harnessVersion: string;
|
|
929
|
+
nodeVersion: string;
|
|
930
|
+
piVersion: string | null;
|
|
931
|
+
registered: boolean;
|
|
932
|
+
tenantId: string | null;
|
|
933
|
+
/**
|
|
934
|
+
* @deprecated The Phase 0 name for the runner id.
|
|
935
|
+
*
|
|
936
|
+
* STILL POPULATED, with the same value as `runnerId`. A deployed container
|
|
937
|
+
* probe reads this field, and a rename that breaks a probe turns a healthy
|
|
938
|
+
* host into an unhealthy one on the next deploy. It goes when nothing reads
|
|
939
|
+
* it — which is a separate, observable change, not a side effect of this one.
|
|
940
|
+
*/
|
|
941
|
+
sandboxId: string | null;
|
|
942
|
+
degraded: boolean;
|
|
943
|
+
degradedReason?: string;
|
|
944
|
+
uptimeMs: number;
|
|
945
|
+
activeSessions: number;
|
|
946
|
+
lastHeartbeatAt: string | null;
|
|
947
|
+
lastHeartbeatError: string | null;
|
|
948
|
+
/** The protocol revision this build speaks. */
|
|
949
|
+
protocolVersion?: number;
|
|
950
|
+
/** THE CANONICAL NAME. Always populated once registered. */
|
|
951
|
+
runnerId?: string | null;
|
|
952
|
+
}
|