@harness-control/runner 0.1.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/LICENSE +202 -0
- package/README.md +21 -0
- package/dist/audit/index.d.ts +18 -0
- package/dist/audit/index.js +28 -0
- package/dist/config/index.d.ts +179 -0
- package/dist/config/index.js +124 -0
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.js +2 -0
- package/dist/connection/runner-connection.d.ts +22 -0
- package/dist/connection/runner-connection.js +631 -0
- package/dist/harnesses/adapters/providers/claude-runtime.d.ts +5 -0
- package/dist/harnesses/adapters/providers/claude-runtime.js +186 -0
- package/dist/harnesses/adapters/providers/claude.d.ts +24 -0
- package/dist/harnesses/adapters/providers/claude.js +189 -0
- package/dist/harnesses/adapters/providers/cli-process.d.ts +44 -0
- package/dist/harnesses/adapters/providers/cli-process.js +195 -0
- package/dist/harnesses/adapters/providers/codex-models.d.ts +4 -0
- package/dist/harnesses/adapters/providers/codex-models.js +62 -0
- package/dist/harnesses/adapters/providers/codex-rpc.d.ts +21 -0
- package/dist/harnesses/adapters/providers/codex-rpc.js +114 -0
- package/dist/harnesses/adapters/providers/codex-runtime.d.ts +3 -0
- package/dist/harnesses/adapters/providers/codex-runtime.js +267 -0
- package/dist/harnesses/adapters/providers/codex.d.ts +22 -0
- package/dist/harnesses/adapters/providers/codex.js +161 -0
- package/dist/harnesses/adapters/providers/mock.d.ts +13 -0
- package/dist/harnesses/adapters/providers/mock.js +64 -0
- package/dist/harnesses/adapters/providers/native-process.d.ts +9 -0
- package/dist/harnesses/adapters/providers/native-process.js +41 -0
- package/dist/harnesses/adapters/providers/native-turn.d.ts +17 -0
- package/dist/harnesses/adapters/providers/native-turn.js +139 -0
- package/dist/harnesses/adapters/providers/opencode.d.ts +44 -0
- package/dist/harnesses/adapters/providers/opencode.js +416 -0
- package/dist/harnesses/adapters/providers/shared.d.ts +9 -0
- package/dist/harnesses/adapters/providers/shared.js +97 -0
- package/dist/harnesses/adapters/registry.d.ts +12 -0
- package/dist/harnesses/adapters/registry.js +47 -0
- package/dist/harnesses/adapters/types.d.ts +54 -0
- package/dist/harnesses/adapters/types.js +9 -0
- package/dist/harnesses/adapters.d.ts +8 -0
- package/dist/harnesses/adapters.js +8 -0
- package/dist/harnesses/index.d.ts +93 -0
- package/dist/harnesses/index.js +620 -0
- package/dist/host/provider-registry.d.ts +34 -0
- package/dist/host/provider-registry.js +162 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +201 -0
- package/dist/local-actions/dispatcher.d.ts +28 -0
- package/dist/local-actions/dispatcher.js +407 -0
- package/dist/local-actions/executors.d.ts +159 -0
- package/dist/local-actions/executors.js +1103 -0
- package/dist/local-actions/index.d.ts +74 -0
- package/dist/local-actions/index.js +275 -0
- package/dist/logs/index.d.ts +6 -0
- package/dist/logs/index.js +9 -0
- package/dist/mcp/McpAttachmentClient.d.ts +111 -0
- package/dist/mcp/McpAttachmentClient.js +345 -0
- package/dist/mcp/McpProxyServer.d.ts +18 -0
- package/dist/mcp/McpProxyServer.js +188 -0
- package/dist/mcp/McpStdioProfileClient.d.ts +19 -0
- package/dist/mcp/McpStdioProfileClient.js +91 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.js +5 -0
- package/dist/mcp/redaction.d.ts +3 -0
- package/dist/mcp/redaction.js +40 -0
- package/dist/pairing/index.d.ts +38 -0
- package/dist/pairing/index.js +180 -0
- package/dist/state/index.d.ts +76 -0
- package/dist/state/index.js +242 -0
- package/dist/workspaces/index.d.ts +13 -0
- package/dist/workspaces/index.js +110 -0
- package/package.json +76 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { WorkspaceManager } from "../workspaces/index.js";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { HCP_MESSAGE_MAX_ENCODED_BYTES, HCP_VERSION, createHcpEnvelope, parseHcpEnvelope, parseJsonHcpMessage, } from "@harness-control/protocol";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
import { HarnessAdapterError } from "../harnesses/adapters.js";
|
|
6
|
+
import { HarnessSessionError, HarnessSessionManager } from "../harnesses/index.js";
|
|
7
|
+
import { ProviderInstanceRegistry } from "../host/provider-registry.js";
|
|
8
|
+
import { LocalActionDispatcher } from "../local-actions/dispatcher.js";
|
|
9
|
+
import { LocalCapabilityExecutor } from "../local-actions/executors.js";
|
|
10
|
+
export class RunnerConnection {
|
|
11
|
+
#config;
|
|
12
|
+
#workspaces;
|
|
13
|
+
#runnerVersion;
|
|
14
|
+
#connectionTokenProvider;
|
|
15
|
+
#onLog;
|
|
16
|
+
#harnessSessions;
|
|
17
|
+
#stateStore;
|
|
18
|
+
#localActionDispatcher;
|
|
19
|
+
#reconnectInitialDelayMs;
|
|
20
|
+
#reconnectMaxDelayMs;
|
|
21
|
+
#socket;
|
|
22
|
+
#heartbeatTimer;
|
|
23
|
+
#reconnectTimer;
|
|
24
|
+
#reconnectAttempt = 0;
|
|
25
|
+
#closing = false;
|
|
26
|
+
#commandRecords = new Map();
|
|
27
|
+
#localActionRecords = new Map();
|
|
28
|
+
#localActionMismatchRecords = new Map();
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.#config = options.config;
|
|
31
|
+
this.#runnerVersion = options.runnerVersion;
|
|
32
|
+
this.#connectionTokenProvider = options.connectionTokenProvider;
|
|
33
|
+
this.#onLog = options.onLog ?? (() => undefined);
|
|
34
|
+
this.#harnessSessions = options.harnessSessions ?? new HarnessSessionManager(options.config);
|
|
35
|
+
this.#stateStore = this.#harnessSessions.stateStore();
|
|
36
|
+
this.#workspaces = new WorkspaceManager(options.config, options.configPath, this.#harnessSessions);
|
|
37
|
+
this.#localActionDispatcher = new LocalActionDispatcher({
|
|
38
|
+
executor: new LocalCapabilityExecutor(this.#harnessSessions.localCapabilityEngine()),
|
|
39
|
+
resolveContext: (payload) => this.#harnessSessions.resolveLocalActionContext(payload),
|
|
40
|
+
emitEvents: (sessionId, turnId, events) => this.#harnessSessions.recordLocalActionEvents(sessionId, turnId, events),
|
|
41
|
+
});
|
|
42
|
+
this.#reconnectInitialDelayMs = options.reconnect?.initialDelayMs ?? 1_000;
|
|
43
|
+
this.#reconnectMaxDelayMs = options.reconnect?.maxDelayMs ?? 30_000;
|
|
44
|
+
}
|
|
45
|
+
async connect() {
|
|
46
|
+
this.#closing = false;
|
|
47
|
+
await this.#openSocket();
|
|
48
|
+
}
|
|
49
|
+
async #openSocket() {
|
|
50
|
+
const connectionToken = this.#connectionTokenProvider
|
|
51
|
+
? await this.#connectionTokenProvider()
|
|
52
|
+
: undefined;
|
|
53
|
+
const socket = new WebSocket(this.#config.control_plane_url, {
|
|
54
|
+
...(connectionToken ? { headers: { authorization: `Bearer ${connectionToken}` } } : {}),
|
|
55
|
+
});
|
|
56
|
+
this.#socket = socket;
|
|
57
|
+
socket.on("message", (data) => {
|
|
58
|
+
this.#handleMessage(data.toString()).catch((error) => {
|
|
59
|
+
this.#onLog(error instanceof Error ? error.message : "Failed to handle control plane message.");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
socket.on("close", () => {
|
|
63
|
+
if (this.#socket === socket) {
|
|
64
|
+
this.#socket = undefined;
|
|
65
|
+
}
|
|
66
|
+
this.#stopHeartbeat();
|
|
67
|
+
this.#onLog("Runner disconnected from control plane.");
|
|
68
|
+
if (!this.#closing) {
|
|
69
|
+
this.#scheduleReconnect();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.on("error", (error) => {
|
|
73
|
+
if (this.#socket === socket) {
|
|
74
|
+
this.#onLog(`Runner connection error: ${error.message}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
await new Promise((resolve, reject) => {
|
|
78
|
+
socket.once("open", () => {
|
|
79
|
+
this.#reconnectAttempt = 0;
|
|
80
|
+
this.#sendHello();
|
|
81
|
+
resolve();
|
|
82
|
+
});
|
|
83
|
+
socket.once("error", reject);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async close() {
|
|
87
|
+
this.#closing = true;
|
|
88
|
+
this.#stopHeartbeat();
|
|
89
|
+
this.#stopReconnect();
|
|
90
|
+
await new Promise((resolve) => {
|
|
91
|
+
if (!this.#socket || this.#socket.readyState === WebSocket.CLOSED) {
|
|
92
|
+
resolve();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.#socket.once("close", () => {
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
this.#socket.close();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
#sendHello() {
|
|
102
|
+
const retainedEvents = this.#harnessSessions.retainedEventRanges();
|
|
103
|
+
const payload = {
|
|
104
|
+
runner_id: this.#config.runner_id,
|
|
105
|
+
host_id: this.#config.host_id ?? this.#config.runner_id,
|
|
106
|
+
runner_version: this.#runnerVersion,
|
|
107
|
+
supported_protocol_versions: [HCP_VERSION],
|
|
108
|
+
capabilities: [
|
|
109
|
+
"providers",
|
|
110
|
+
"workspaces",
|
|
111
|
+
"mcp_streamable_http",
|
|
112
|
+
"local_filesystem",
|
|
113
|
+
"local_git",
|
|
114
|
+
"local_shell",
|
|
115
|
+
"local_dev_server",
|
|
116
|
+
"durable_at_least_once",
|
|
117
|
+
"session_snapshots",
|
|
118
|
+
],
|
|
119
|
+
...(retainedEvents ? { retained_events: retainedEvents } : {}),
|
|
120
|
+
};
|
|
121
|
+
this.#send(createHcpEnvelope("host.hello", payload));
|
|
122
|
+
}
|
|
123
|
+
async #handleMessage(raw) {
|
|
124
|
+
if (Buffer.byteLength(raw, "utf8") > HCP_MESSAGE_MAX_ENCODED_BYTES) {
|
|
125
|
+
this.#sendNack("unknown", {
|
|
126
|
+
code: "message_too_large",
|
|
127
|
+
message: `Control plane message exceeds ${HCP_MESSAGE_MAX_ENCODED_BYTES} encoded bytes.`,
|
|
128
|
+
retryable: false,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
let envelope;
|
|
133
|
+
try {
|
|
134
|
+
envelope = parseJsonHcpMessage(raw);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
this.#sendParseNack(raw, error);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
switch (envelope.type) {
|
|
141
|
+
case "host.accepted":
|
|
142
|
+
await this.#handleAccepted(envelope);
|
|
143
|
+
return;
|
|
144
|
+
case "host.rejected":
|
|
145
|
+
this.#onLog(`Control plane rejected runner: ${envelope.payload.reason}`);
|
|
146
|
+
await this.close();
|
|
147
|
+
return;
|
|
148
|
+
case "host.workspaces.request": {
|
|
149
|
+
const result = await this.#workspaces.execute(envelope.id, envelope.payload);
|
|
150
|
+
this.#send(createHcpEnvelope("host.workspaces.result", result));
|
|
151
|
+
await this.#sendCapabilities();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
case "host.workspaces.result":
|
|
155
|
+
return;
|
|
156
|
+
case "harness.session.start":
|
|
157
|
+
await this.#handleCommand(envelope, (message) => this.#handleSessionStart(message));
|
|
158
|
+
return;
|
|
159
|
+
case "harness.session.snapshot.request":
|
|
160
|
+
await this.#handleCommand(envelope, (message) => ({
|
|
161
|
+
events: [],
|
|
162
|
+
snapshotPayload: this.#harnessSessions.sessionSnapshot(message.id, message.payload.session_id),
|
|
163
|
+
}));
|
|
164
|
+
return;
|
|
165
|
+
case "harness.turn.send":
|
|
166
|
+
await this.#handleCommand(envelope, (message) => this.#handleTurnSend(message));
|
|
167
|
+
return;
|
|
168
|
+
case "harness.turn.cancel":
|
|
169
|
+
await this.#handleCommand(envelope, (message) => this.#handleTurnCancel(message));
|
|
170
|
+
return;
|
|
171
|
+
case "harness.session.stop":
|
|
172
|
+
await this.#handleCommand(envelope, (message) => this.#handleSessionStop(message));
|
|
173
|
+
return;
|
|
174
|
+
case "harness.approval.respond":
|
|
175
|
+
case "harness.input.respond":
|
|
176
|
+
case "tool_servers.detach":
|
|
177
|
+
await this.#handleCommand(envelope, () => {
|
|
178
|
+
throw new HarnessAdapterError("unsupported_command", `${envelope.type} is not implemented by this runner.`);
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
case "local.action.request":
|
|
182
|
+
await this.#handleLocalAction(envelope);
|
|
183
|
+
return;
|
|
184
|
+
case "hcp.command.ack":
|
|
185
|
+
case "hcp.command.nack":
|
|
186
|
+
case "host.hello":
|
|
187
|
+
case "host.heartbeat":
|
|
188
|
+
case "host.capabilities.updated":
|
|
189
|
+
case "host.replay.unavailable":
|
|
190
|
+
case "harness.session.snapshot":
|
|
191
|
+
case "local.action.response":
|
|
192
|
+
case "local.action.error":
|
|
193
|
+
case "harness.event":
|
|
194
|
+
this.#onLog(`Ignoring inbound ${envelope.type}; it is not a runner command.`);
|
|
195
|
+
return;
|
|
196
|
+
default:
|
|
197
|
+
const unsupportedEnvelope = envelope;
|
|
198
|
+
this.#sendNack(commandIdFor(unsupportedEnvelope), {
|
|
199
|
+
code: "unsupported_message_type",
|
|
200
|
+
message: `${unsupportedEnvelope.type} is not supported as an inbound runner command.`,
|
|
201
|
+
retryable: false,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async #handleAccepted(envelope) {
|
|
206
|
+
this.#onLog(`Control plane accepted ${envelope.payload.protocol_version}.`);
|
|
207
|
+
await this.#sendCapabilities();
|
|
208
|
+
this.#replayRequestedEvents(envelope.payload.resume);
|
|
209
|
+
this.#startHeartbeat(envelope.payload.heartbeat_interval_seconds);
|
|
210
|
+
}
|
|
211
|
+
#replayRequestedEvents(cursor) {
|
|
212
|
+
if (!cursor) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const replay = this.#harnessSessions.replayEventsAfter(cursor);
|
|
216
|
+
for (const unavailable of replay.unavailable) {
|
|
217
|
+
this.#send(createHcpEnvelope("host.replay.unavailable", unavailable));
|
|
218
|
+
}
|
|
219
|
+
for (const event of replay.events) {
|
|
220
|
+
this.#send(createHcpEnvelope("harness.event", event));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async #handleCommand(envelope, handler) {
|
|
224
|
+
const commandId = commandIdFor(envelope);
|
|
225
|
+
const commandHash = hashCommandPayload(envelope);
|
|
226
|
+
const persistedReceipt = this.#stateStore.getCommandReceipt(commandId);
|
|
227
|
+
const existingRecord = this.#commandRecords.get(commandId) ?? (persistedReceipt ? commandRecordFromReceipt(persistedReceipt) : undefined);
|
|
228
|
+
if (existingRecord !== undefined) {
|
|
229
|
+
if (existingRecord.payloadHash === commandHash) {
|
|
230
|
+
if (existingRecord.outcome === "pending") {
|
|
231
|
+
const settledRecord = await existingRecord.completion;
|
|
232
|
+
if (settledRecord.outcome === "ack") {
|
|
233
|
+
this.#sendAck(commandId, true);
|
|
234
|
+
if (settledRecord.snapshotPayload) {
|
|
235
|
+
this.#send(createHcpEnvelope("harness.session.snapshot", settledRecord.snapshotPayload));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
this.#sendNackPayload(settledRecord.nackPayload);
|
|
240
|
+
}
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (existingRecord.outcome === "ack") {
|
|
244
|
+
this.#sendAck(commandId, true);
|
|
245
|
+
if (existingRecord.snapshotPayload) {
|
|
246
|
+
this.#send(createHcpEnvelope("harness.session.snapshot", existingRecord.snapshotPayload));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
this.#sendNackPayload(existingRecord.nackPayload);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.#sendNack(commandId, {
|
|
255
|
+
code: "duplicate_command_payload_mismatch",
|
|
256
|
+
message: `Command '${commandId}' was already seen with a different payload.`,
|
|
257
|
+
retryable: false,
|
|
258
|
+
});
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let settleCommand;
|
|
262
|
+
const completion = new Promise((resolve) => {
|
|
263
|
+
settleCommand = resolve;
|
|
264
|
+
});
|
|
265
|
+
this.#commandRecords.set(commandId, {
|
|
266
|
+
payloadHash: commandHash,
|
|
267
|
+
outcome: "pending",
|
|
268
|
+
completion,
|
|
269
|
+
});
|
|
270
|
+
try {
|
|
271
|
+
const execution = await handler(envelope);
|
|
272
|
+
const result = Array.isArray(execution) ? { events: execution } : execution;
|
|
273
|
+
const settledAt = new Date().toISOString();
|
|
274
|
+
const record = {
|
|
275
|
+
payloadHash: commandHash,
|
|
276
|
+
outcome: "ack",
|
|
277
|
+
...(result.snapshotPayload ? { snapshotPayload: result.snapshotPayload } : {}),
|
|
278
|
+
};
|
|
279
|
+
this.#commandRecords.set(commandId, record);
|
|
280
|
+
this.#stateStore.setCommandReceipt(commandId, {
|
|
281
|
+
payloadHash: commandHash,
|
|
282
|
+
outcome: "ack",
|
|
283
|
+
settledAt,
|
|
284
|
+
...(result.snapshotPayload ? { snapshotPayload: result.snapshotPayload } : {}),
|
|
285
|
+
});
|
|
286
|
+
settleCommand(record);
|
|
287
|
+
this.#sendAck(commandId, false);
|
|
288
|
+
if (result.snapshotPayload) {
|
|
289
|
+
this.#send(createHcpEnvelope("harness.session.snapshot", result.snapshotPayload));
|
|
290
|
+
}
|
|
291
|
+
for (const event of result.events) {
|
|
292
|
+
this.#send(createHcpEnvelope("harness.event", event));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
const nackPayload = createNackPayload(commandId, toHcpError(error));
|
|
297
|
+
const record = {
|
|
298
|
+
payloadHash: commandHash,
|
|
299
|
+
outcome: "nack",
|
|
300
|
+
nackPayload,
|
|
301
|
+
};
|
|
302
|
+
this.#commandRecords.set(commandId, record);
|
|
303
|
+
this.#stateStore.setCommandReceipt(commandId, {
|
|
304
|
+
payloadHash: commandHash,
|
|
305
|
+
outcome: "nack",
|
|
306
|
+
settledAt: new Date().toISOString(),
|
|
307
|
+
nackPayload,
|
|
308
|
+
});
|
|
309
|
+
settleCommand(record);
|
|
310
|
+
this.#sendNackPayload(nackPayload);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async #handleSessionStart(envelope) {
|
|
314
|
+
this.#localActionDispatcher.markSessionActive(envelope.payload.session_id);
|
|
315
|
+
return await this.#harnessSessions.startSession(envelope.payload);
|
|
316
|
+
}
|
|
317
|
+
#handleTurnSend(envelope) {
|
|
318
|
+
const completion = this.#harnessSessions.sendTurn(envelope.payload, (event) => this.#sendEventIfConnected(event));
|
|
319
|
+
completion
|
|
320
|
+
.then((events) => {
|
|
321
|
+
for (const event of events) {
|
|
322
|
+
this.#sendEventIfConnected(event);
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
.catch((error) => {
|
|
326
|
+
const failedEvent = this.#harnessSessions.recordTurnFailure(envelope.payload.session_id, envelope.payload.turn_id, error);
|
|
327
|
+
this.#sendEventIfConnected(failedEvent);
|
|
328
|
+
});
|
|
329
|
+
return [];
|
|
330
|
+
}
|
|
331
|
+
#handleTurnCancel(envelope) {
|
|
332
|
+
return this.#harnessSessions.cancelTurn(envelope.payload.session_id, envelope.payload.turn_id);
|
|
333
|
+
}
|
|
334
|
+
async #handleSessionStop(envelope) {
|
|
335
|
+
const events = await this.#harnessSessions.stopSession(envelope.payload.session_id, envelope.payload.reason);
|
|
336
|
+
await this.#localActionDispatcher.stopDevServersForSession(envelope.payload.session_id);
|
|
337
|
+
return events;
|
|
338
|
+
}
|
|
339
|
+
async #handleLocalAction(envelope) {
|
|
340
|
+
const requestId = envelope.payload.request_id;
|
|
341
|
+
const payloadHash = hashCommandPayload(envelope);
|
|
342
|
+
const persistedReceipt = this.#stateStore.getLocalActionReceipt(requestId);
|
|
343
|
+
const existingRecord = this.#localActionRecords.get(requestId) ??
|
|
344
|
+
(persistedReceipt ? localActionRecordFromReceipt(persistedReceipt) : undefined);
|
|
345
|
+
if (existingRecord !== undefined) {
|
|
346
|
+
if (existingRecord.payloadHash === payloadHash) {
|
|
347
|
+
if (existingRecord.outcome === "pending") {
|
|
348
|
+
const settledRecord = await existingRecord.completion;
|
|
349
|
+
this.#sendLocalActionRecord(settledRecord);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
this.#sendLocalActionRecord(existingRecord);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const mismatchKey = `${requestId}:${payloadHash}`;
|
|
356
|
+
const existingMismatchRecord = this.#localActionMismatchRecords.get(mismatchKey);
|
|
357
|
+
if (existingMismatchRecord) {
|
|
358
|
+
this.#sendLocalActionRecord(existingMismatchRecord);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const mismatchOutcome = this.#localActionDispatcher.duplicatePayloadMismatch(envelope.payload);
|
|
362
|
+
const mismatchRecord = mismatchOutcome.type === "response"
|
|
363
|
+
? {
|
|
364
|
+
payloadHash,
|
|
365
|
+
requestPayload: envelope.payload,
|
|
366
|
+
outcome: "response",
|
|
367
|
+
payload: mismatchOutcome.payload,
|
|
368
|
+
}
|
|
369
|
+
: {
|
|
370
|
+
payloadHash,
|
|
371
|
+
requestPayload: envelope.payload,
|
|
372
|
+
outcome: "error",
|
|
373
|
+
payload: mismatchOutcome.payload,
|
|
374
|
+
};
|
|
375
|
+
this.#localActionMismatchRecords.set(mismatchKey, mismatchRecord);
|
|
376
|
+
for (const event of mismatchOutcome.events) {
|
|
377
|
+
this.#send(createHcpEnvelope("harness.event", event));
|
|
378
|
+
}
|
|
379
|
+
this.#sendLocalActionRecord(mismatchRecord);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
let settleLocalAction;
|
|
383
|
+
const completion = new Promise((resolve) => {
|
|
384
|
+
settleLocalAction = resolve;
|
|
385
|
+
});
|
|
386
|
+
this.#localActionRecords.set(requestId, {
|
|
387
|
+
payloadHash,
|
|
388
|
+
requestPayload: envelope.payload,
|
|
389
|
+
outcome: "pending",
|
|
390
|
+
completion,
|
|
391
|
+
});
|
|
392
|
+
const outcome = await this.#localActionDispatcher.dispatch(envelope.payload);
|
|
393
|
+
const record = outcome.type === "response"
|
|
394
|
+
? {
|
|
395
|
+
payloadHash,
|
|
396
|
+
requestPayload: envelope.payload,
|
|
397
|
+
outcome: "response",
|
|
398
|
+
payload: outcome.payload,
|
|
399
|
+
}
|
|
400
|
+
: {
|
|
401
|
+
payloadHash,
|
|
402
|
+
requestPayload: envelope.payload,
|
|
403
|
+
outcome: "error",
|
|
404
|
+
payload: outcome.payload,
|
|
405
|
+
};
|
|
406
|
+
this.#localActionRecords.set(requestId, record);
|
|
407
|
+
const persistedRecord = record.outcome === "response"
|
|
408
|
+
? {
|
|
409
|
+
payloadHash,
|
|
410
|
+
requestPayload: envelope.payload,
|
|
411
|
+
outcome: "response",
|
|
412
|
+
settledAt: new Date().toISOString(),
|
|
413
|
+
payload: record.payload,
|
|
414
|
+
}
|
|
415
|
+
: {
|
|
416
|
+
payloadHash,
|
|
417
|
+
requestPayload: envelope.payload,
|
|
418
|
+
outcome: "error",
|
|
419
|
+
settledAt: new Date().toISOString(),
|
|
420
|
+
payload: record.payload,
|
|
421
|
+
};
|
|
422
|
+
this.#stateStore.setLocalActionReceipt(requestId, persistedRecord);
|
|
423
|
+
settleLocalAction(record);
|
|
424
|
+
for (const event of outcome.events) {
|
|
425
|
+
this.#send(createHcpEnvelope("harness.event", event));
|
|
426
|
+
}
|
|
427
|
+
this.#sendLocalActionRecord(record);
|
|
428
|
+
}
|
|
429
|
+
async #sendCapabilities() {
|
|
430
|
+
const registry = new ProviderInstanceRegistry(this.#config, await this.#harnessSessions.providerDriverStatuses());
|
|
431
|
+
const payload = { ...registry.snapshot(), workspace_management: this.#workspaces.snapshot() };
|
|
432
|
+
this.#send(createHcpEnvelope("host.capabilities.updated", payload));
|
|
433
|
+
}
|
|
434
|
+
#startHeartbeat(intervalSeconds) {
|
|
435
|
+
this.#stopHeartbeat();
|
|
436
|
+
const intervalMs = Math.max(1, intervalSeconds) * 1000;
|
|
437
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
438
|
+
const payload = {
|
|
439
|
+
host_id: this.#config.host_id ?? this.#config.runner_id,
|
|
440
|
+
status: "online",
|
|
441
|
+
active_sessions: this.#harnessSessions.activeSessionCount(),
|
|
442
|
+
};
|
|
443
|
+
this.#send(createHcpEnvelope("host.heartbeat", payload));
|
|
444
|
+
}, intervalMs);
|
|
445
|
+
}
|
|
446
|
+
#stopHeartbeat() {
|
|
447
|
+
if (this.#heartbeatTimer) {
|
|
448
|
+
clearInterval(this.#heartbeatTimer);
|
|
449
|
+
this.#heartbeatTimer = undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
#scheduleReconnect() {
|
|
453
|
+
if (this.#reconnectTimer) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const delayMs = Math.min(this.#reconnectMaxDelayMs, this.#reconnectInitialDelayMs * 2 ** this.#reconnectAttempt);
|
|
457
|
+
this.#reconnectAttempt += 1;
|
|
458
|
+
this.#reconnectTimer = setTimeout(() => {
|
|
459
|
+
this.#reconnectTimer = undefined;
|
|
460
|
+
if (this.#closing) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.#openSocket().catch((error) => {
|
|
464
|
+
this.#onLog(error instanceof Error ? error.message : "Runner reconnect failed.");
|
|
465
|
+
if (!this.#closing) {
|
|
466
|
+
this.#scheduleReconnect();
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}, delayMs);
|
|
470
|
+
}
|
|
471
|
+
#stopReconnect() {
|
|
472
|
+
if (this.#reconnectTimer) {
|
|
473
|
+
clearTimeout(this.#reconnectTimer);
|
|
474
|
+
this.#reconnectTimer = undefined;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
#send(envelope) {
|
|
478
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
479
|
+
throw new Error("Runner is not connected to the control plane.");
|
|
480
|
+
}
|
|
481
|
+
this.#socket.send(JSON.stringify(envelope));
|
|
482
|
+
}
|
|
483
|
+
#sendEventIfConnected(event) {
|
|
484
|
+
if (!this.#socket || this.#socket.readyState !== WebSocket.OPEN) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
this.#socket.send(JSON.stringify(createHcpEnvelope("harness.event", event)));
|
|
488
|
+
}
|
|
489
|
+
#sendNack(commandId, error) {
|
|
490
|
+
this.#sendNackPayload(createNackPayload(commandId, error));
|
|
491
|
+
}
|
|
492
|
+
#sendNackPayload(payload) {
|
|
493
|
+
this.#send(createHcpEnvelope("hcp.command.nack", payload));
|
|
494
|
+
}
|
|
495
|
+
#sendAck(commandId, duplicate) {
|
|
496
|
+
const payload = {
|
|
497
|
+
command_id: commandId,
|
|
498
|
+
accepted_at: new Date().toISOString(),
|
|
499
|
+
duplicate,
|
|
500
|
+
};
|
|
501
|
+
this.#send(createHcpEnvelope("hcp.command.ack", payload));
|
|
502
|
+
}
|
|
503
|
+
#sendLocalActionRecord(record) {
|
|
504
|
+
if (record.outcome === "response") {
|
|
505
|
+
this.#send(createHcpEnvelope("local.action.response", record.payload));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
this.#send(createHcpEnvelope("local.action.error", record.payload));
|
|
509
|
+
}
|
|
510
|
+
#sendParseNack(raw, error) {
|
|
511
|
+
let receivedMessageId = "unknown";
|
|
512
|
+
let payloadHash;
|
|
513
|
+
try {
|
|
514
|
+
const envelope = parseHcpEnvelope(JSON.parse(raw));
|
|
515
|
+
receivedMessageId = envelope.id;
|
|
516
|
+
payloadHash = createHash("sha256").update(stableStringify(envelope.payload)).digest("hex");
|
|
517
|
+
}
|
|
518
|
+
catch (parseError) {
|
|
519
|
+
if (!(parseError instanceof Error)) {
|
|
520
|
+
throw parseError;
|
|
521
|
+
}
|
|
522
|
+
receivedMessageId = "unknown";
|
|
523
|
+
}
|
|
524
|
+
const nackPayload = createNackPayload(receivedMessageId, {
|
|
525
|
+
code: "invalid_message",
|
|
526
|
+
message: error instanceof Error ? error.message : "Control plane message failed validation.",
|
|
527
|
+
retryable: false,
|
|
528
|
+
});
|
|
529
|
+
if (payloadHash !== undefined) {
|
|
530
|
+
const record = {
|
|
531
|
+
payloadHash,
|
|
532
|
+
outcome: "nack",
|
|
533
|
+
nackPayload,
|
|
534
|
+
};
|
|
535
|
+
this.#commandRecords.set(receivedMessageId, record);
|
|
536
|
+
this.#stateStore.setCommandReceipt(receivedMessageId, {
|
|
537
|
+
payloadHash,
|
|
538
|
+
outcome: "nack",
|
|
539
|
+
settledAt: new Date().toISOString(),
|
|
540
|
+
nackPayload,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
this.#sendNackPayload(nackPayload);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function toHcpError(error) {
|
|
547
|
+
if (error instanceof HarnessSessionError) {
|
|
548
|
+
return {
|
|
549
|
+
code: error.code,
|
|
550
|
+
message: error.message,
|
|
551
|
+
retryable: false,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (error instanceof HarnessAdapterError) {
|
|
555
|
+
return {
|
|
556
|
+
code: error.code,
|
|
557
|
+
message: error.message,
|
|
558
|
+
retryable: false,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
if (error instanceof Error) {
|
|
562
|
+
return {
|
|
563
|
+
code: "runner_error",
|
|
564
|
+
message: error.message,
|
|
565
|
+
retryable: false,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
code: "runner_error",
|
|
570
|
+
message: "Runner command failed.",
|
|
571
|
+
retryable: false,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function commandRecordFromReceipt(receipt) {
|
|
575
|
+
return receipt.outcome === "ack"
|
|
576
|
+
? {
|
|
577
|
+
payloadHash: receipt.payloadHash,
|
|
578
|
+
outcome: "ack",
|
|
579
|
+
...(receipt.snapshotPayload ? { snapshotPayload: receipt.snapshotPayload } : {}),
|
|
580
|
+
}
|
|
581
|
+
: {
|
|
582
|
+
payloadHash: receipt.payloadHash,
|
|
583
|
+
outcome: "nack",
|
|
584
|
+
nackPayload: receipt.nackPayload,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function localActionRecordFromReceipt(receipt) {
|
|
588
|
+
return receipt.outcome === "response"
|
|
589
|
+
? {
|
|
590
|
+
payloadHash: receipt.payloadHash,
|
|
591
|
+
requestPayload: receipt.requestPayload,
|
|
592
|
+
outcome: "response",
|
|
593
|
+
payload: receipt.payload,
|
|
594
|
+
}
|
|
595
|
+
: {
|
|
596
|
+
payloadHash: receipt.payloadHash,
|
|
597
|
+
requestPayload: receipt.requestPayload,
|
|
598
|
+
outcome: "error",
|
|
599
|
+
payload: receipt.payload,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
function createNackPayload(commandId, error) {
|
|
603
|
+
return {
|
|
604
|
+
command_id: commandId,
|
|
605
|
+
rejected_at: new Date().toISOString(),
|
|
606
|
+
error,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function commandIdFor(envelope) {
|
|
610
|
+
if (typeof envelope.payload === "object" && envelope.payload !== null && "command_id" in envelope.payload) {
|
|
611
|
+
const commandId = envelope.payload.command_id;
|
|
612
|
+
if (typeof commandId === "string" && commandId.length > 0) {
|
|
613
|
+
return commandId;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return envelope.id;
|
|
617
|
+
}
|
|
618
|
+
function hashCommandPayload(envelope) {
|
|
619
|
+
return createHash("sha256").update(stableStringify(envelope.payload)).digest("hex");
|
|
620
|
+
}
|
|
621
|
+
function stableStringify(value) {
|
|
622
|
+
if (value === null || typeof value !== "object") {
|
|
623
|
+
return JSON.stringify(value);
|
|
624
|
+
}
|
|
625
|
+
if (Array.isArray(value)) {
|
|
626
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
627
|
+
}
|
|
628
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
629
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`).join(",")}}`;
|
|
630
|
+
}
|
|
631
|
+
//# sourceMappingURL=runner-connection.js.map
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
+
import { type NativeTurn } from "./native-turn.js";
|
|
3
|
+
export type ClaudeQueryFactory = (input: Parameters<typeof query>[0]) => Query;
|
|
4
|
+
export declare function createClaudeTurn(queryFactory?: ClaudeQueryFactory): NativeTurn;
|
|
5
|
+
//# sourceMappingURL=claude-runtime.d.ts.map
|