@cozibud/harness 0.1.0 → 0.2.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/README.md +54 -24
- package/dist/cli.js +108 -19
- package/dist/contracts.d.ts +4 -0
- package/dist/cursor-workspace.d.ts +3 -0
- package/dist/cursor-workspace.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pairing-secret.d.ts +8 -0
- package/dist/pairing-secret.js +34 -0
- package/dist/pairing.d.ts +14 -0
- package/dist/pairing.js +89 -0
- package/dist/process-driver.d.ts +2 -2
- package/dist/process-driver.js +202 -124
- package/dist/relay-client.d.ts +69 -0
- package/dist/relay-client.js +166 -0
- package/dist/relay-protocol.d.ts +18 -0
- package/dist/relay-protocol.js +3 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +15 -7
- package/package.json +43 -43
package/dist/process-driver.js
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { delimiter, isAbsolute, join } from "node:path";
|
|
5
|
+
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { StringDecoder } from "node:string_decoder";
|
|
7
|
+
import { ensureCursorWorkspace } from "./cursor-workspace.js";
|
|
7
8
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
8
9
|
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
9
10
|
const TEMP_DIRECTORY_CLEANUP_OPTIONS = { recursive: true, force: true, maxRetries: 5, retryDelay: 100 };
|
|
11
|
+
let cursorRunTail = Promise.resolve();
|
|
12
|
+
async function acquireCursorRun(signal, label) {
|
|
13
|
+
const predecessor = cursorRunTail.catch(() => undefined);
|
|
14
|
+
let release;
|
|
15
|
+
const held = new Promise((resolve) => { release = resolve; });
|
|
16
|
+
cursorRunTail = predecessor.then(() => held);
|
|
17
|
+
if (signal.aborted) {
|
|
18
|
+
void predecessor.then(release);
|
|
19
|
+
throw new Error(`${label} was cancelled.`);
|
|
20
|
+
}
|
|
21
|
+
let cancelWait;
|
|
22
|
+
const cancelled = new Promise((_, reject) => {
|
|
23
|
+
cancelWait = () => reject(new Error(`${label} was cancelled.`));
|
|
24
|
+
signal.addEventListener("abort", cancelWait, { once: true });
|
|
25
|
+
});
|
|
26
|
+
try {
|
|
27
|
+
await Promise.race([predecessor, cancelled]);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
void predecessor.then(release);
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
signal.removeEventListener("abort", cancelWait);
|
|
35
|
+
}
|
|
36
|
+
return release;
|
|
37
|
+
}
|
|
10
38
|
function stopProcess(child) {
|
|
11
39
|
if (!child.pid || child.exitCode !== null)
|
|
12
40
|
return;
|
|
@@ -24,6 +52,8 @@ function stopProcess(child) {
|
|
|
24
52
|
}
|
|
25
53
|
}
|
|
26
54
|
function carePrompt(input) {
|
|
55
|
+
if (input.interpretation)
|
|
56
|
+
return `${input.interpretation}\n<caregiver_request>\n${input.prompt}\n</caregiver_request>`;
|
|
27
57
|
const origin = `${input.client} ${input.source}`;
|
|
28
58
|
return [
|
|
29
59
|
"You are handling a Cozibud family-care request.",
|
|
@@ -31,6 +61,10 @@ function carePrompt(input) {
|
|
|
31
61
|
"Do not inspect or modify local files and do not run shell commands.",
|
|
32
62
|
"Ask a short follow-up only when ambiguity would make a care record incorrect.",
|
|
33
63
|
"Otherwise return a concise, spoken-friendly answer and confirm writes only after the tool succeeds.",
|
|
64
|
+
...(input.careRecordIdempotencyKey ? [
|
|
65
|
+
`For every Cozibud care-record write in this turn, pass clientEventId exactly ${input.careRecordIdempotencyKey}.`,
|
|
66
|
+
"Do not claim that a care record was saved unless the Cozibud tool succeeds with that identity.",
|
|
67
|
+
] : []),
|
|
34
68
|
`Input origin: ${origin}. Locale: ${input.locale ?? "unspecified"}.`,
|
|
35
69
|
"<caregiver_request>",
|
|
36
70
|
input.prompt,
|
|
@@ -88,8 +122,9 @@ export function createProcessHarnessDriver(definition, options = {}) {
|
|
|
88
122
|
command: definition.command,
|
|
89
123
|
authentication: "existing-login",
|
|
90
124
|
async probe() {
|
|
125
|
+
const probeCwd = definition.id === "cursor" ? ensureCursorWorkspace(options.cwd) : options.cwd;
|
|
91
126
|
return await new Promise((resolve) => {
|
|
92
|
-
const child = spawn(definition.command, definition.versionArgs ?? ["--version"], { ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] });
|
|
127
|
+
const child = spawn(definition.command, definition.versionArgs ?? ["--version"], { ...spawnOptions, cwd: probeCwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
93
128
|
let output = "";
|
|
94
129
|
let bytes = 0;
|
|
95
130
|
let overflow = false;
|
|
@@ -124,130 +159,139 @@ export function createProcessHarnessDriver(definition, options = {}) {
|
|
|
124
159
|
async run(input, context) {
|
|
125
160
|
if (context.signal.aborted)
|
|
126
161
|
throw new Error(`${definition.label} was cancelled.`);
|
|
127
|
-
const
|
|
128
|
-
let turnError;
|
|
162
|
+
const releaseCursor = definition.id === "cursor" ? await acquireCursorRun(context.signal, definition.label) : undefined;
|
|
129
163
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
if (error)
|
|
156
|
-
reject(error);
|
|
157
|
-
else
|
|
158
|
-
resolve({ text: response.trim() });
|
|
159
|
-
};
|
|
160
|
-
const abort = () => stop();
|
|
161
|
-
context.signal.addEventListener("abort", abort, { once: true });
|
|
162
|
-
const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
163
|
-
const lineReceived = (line) => {
|
|
164
|
-
if (failure || context.signal.aborted || timedOut)
|
|
165
|
-
return;
|
|
166
|
-
try {
|
|
167
|
-
const event = JSON.parse(line);
|
|
168
|
-
if (event?.type === "error" && event.severity === "warning")
|
|
164
|
+
const stableCwd = definition.id === "cursor" ? ensureCursorWorkspace(options.cwd) : options.cwd;
|
|
165
|
+
const ephemeralCwd = stableCwd ? null : await mkdtemp(join(tmpdir(), "cozibud-harness-"));
|
|
166
|
+
let turnError;
|
|
167
|
+
try {
|
|
168
|
+
if (context.signal.aborted)
|
|
169
|
+
throw new Error(`${definition.label} was cancelled.`);
|
|
170
|
+
return await new Promise((resolve, reject) => {
|
|
171
|
+
const args = [...definition.args(input), ...(ephemeralCwd ? definition.emptyDirectoryArgs ?? [] : [])];
|
|
172
|
+
const child = spawn(definition.command, args, { ...spawnOptions, cwd: stableCwd ?? ephemeralCwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
173
|
+
child.stdin.on("error", () => { });
|
|
174
|
+
child.stdin.end(carePrompt(input));
|
|
175
|
+
const decoder = new StringDecoder("utf8");
|
|
176
|
+
let pending = "";
|
|
177
|
+
let response = "";
|
|
178
|
+
let outputBytes = 0;
|
|
179
|
+
let settled = false;
|
|
180
|
+
let timedOut = false;
|
|
181
|
+
let failure;
|
|
182
|
+
let stopping = false;
|
|
183
|
+
const stop = () => { if (!stopping) {
|
|
184
|
+
stopping = true;
|
|
185
|
+
stopProcess(child);
|
|
186
|
+
} };
|
|
187
|
+
const finish = (error) => {
|
|
188
|
+
if (settled)
|
|
169
189
|
return;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
190
|
+
settled = true;
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
context.signal.removeEventListener("abort", abort);
|
|
193
|
+
if (error)
|
|
194
|
+
reject(error);
|
|
195
|
+
else
|
|
196
|
+
resolve({ text: response.trim() });
|
|
197
|
+
};
|
|
198
|
+
const abort = () => stop();
|
|
199
|
+
context.signal.addEventListener("abort", abort, { once: true });
|
|
200
|
+
const timer = setTimeout(() => { timedOut = true; stop(); }, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
201
|
+
const lineReceived = (line) => {
|
|
202
|
+
if (failure || context.signal.aborted || timedOut)
|
|
173
203
|
return;
|
|
204
|
+
try {
|
|
205
|
+
const event = JSON.parse(line);
|
|
206
|
+
if (event?.type === "error" && event.severity === "warning")
|
|
207
|
+
return;
|
|
208
|
+
if (event && (event.is_error === true || event.type === "error" || event.type === "turn.failed" || event.error)) {
|
|
209
|
+
failure = new Error(`${definition.label} reported a failed turn. Check the client's sign-in, subscription, and Cozibud MCP authorization, then retry.`);
|
|
210
|
+
stop();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const candidate = messageText(event);
|
|
214
|
+
if (!candidate)
|
|
215
|
+
return;
|
|
216
|
+
const next = event.type === "message" && event.delta === true
|
|
217
|
+
? { value: response + candidate, delta: candidate }
|
|
218
|
+
: appendNovelText(response, candidate);
|
|
219
|
+
response = next.value;
|
|
220
|
+
if (next.delta)
|
|
221
|
+
context.emit({ type: "assistant.delta", text: next.delta });
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Never return provider diagnostics as household answers.
|
|
174
225
|
}
|
|
175
|
-
|
|
176
|
-
|
|
226
|
+
};
|
|
227
|
+
const count = (chunk) => {
|
|
228
|
+
outputBytes += chunk.length;
|
|
229
|
+
if (outputBytes <= MAX_OUTPUT_BYTES && !failure)
|
|
230
|
+
return true;
|
|
231
|
+
if (!failure) {
|
|
232
|
+
failure = new Error("Harness output exceeded the 1 MiB safety limit.");
|
|
233
|
+
pending = "";
|
|
234
|
+
stop();
|
|
235
|
+
}
|
|
236
|
+
return false;
|
|
237
|
+
};
|
|
238
|
+
child.stderr.on("data", (chunk) => { count(chunk); });
|
|
239
|
+
child.stdout.on("data", (chunk) => {
|
|
240
|
+
if (!count(chunk))
|
|
177
241
|
return;
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
242
|
+
pending += decoder.write(chunk);
|
|
243
|
+
let newline;
|
|
244
|
+
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
245
|
+
const line = pending.slice(0, newline);
|
|
246
|
+
pending = pending.slice(newline + 1);
|
|
247
|
+
lineReceived(line);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
child.once("error", (error) => {
|
|
251
|
+
finish(new Error(error.code === "ENOENT" ? `${definition.command} is not installed or is not on PATH.` : `${definition.label} failed to start.`));
|
|
252
|
+
});
|
|
253
|
+
child.once("close", (code) => {
|
|
254
|
+
if (failure)
|
|
255
|
+
return finish(failure);
|
|
256
|
+
if (timedOut)
|
|
257
|
+
return finish(new Error(`${definition.label} timed out.`));
|
|
258
|
+
if (context.signal.aborted)
|
|
259
|
+
return finish(new Error(`${definition.label} was cancelled.`));
|
|
260
|
+
if (code !== 0)
|
|
261
|
+
return finish(new Error(`${definition.label} exited with code ${code ?? "unknown"}. Check the client's sign-in, subscription, and Cozibud MCP authorization.`));
|
|
262
|
+
lineReceived(pending + decoder.end());
|
|
263
|
+
if (failure)
|
|
264
|
+
return finish(failure);
|
|
265
|
+
if (!response.trim())
|
|
266
|
+
return finish(new Error(`${definition.label} returned no assistant response.`));
|
|
267
|
+
finish();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
turnError = error;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
if (definition.id === "cursor" && stableCwd)
|
|
277
|
+
ensureCursorWorkspace(stableCwd);
|
|
278
|
+
if (ephemeralCwd) {
|
|
279
|
+
try {
|
|
280
|
+
await (options.removeDirectory ?? rm)(ephemeralCwd, TEMP_DIRECTORY_CLEANUP_OPTIONS);
|
|
184
281
|
}
|
|
185
282
|
catch {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
if (outputBytes <= MAX_OUTPUT_BYTES && !failure)
|
|
192
|
-
return true;
|
|
193
|
-
if (!failure) {
|
|
194
|
-
failure = new Error("Harness output exceeded the 1 MiB safety limit.");
|
|
195
|
-
pending = "";
|
|
196
|
-
stop();
|
|
197
|
-
}
|
|
198
|
-
return false;
|
|
199
|
-
};
|
|
200
|
-
child.stderr.on("data", (chunk) => { count(chunk); });
|
|
201
|
-
child.stdout.on("data", (chunk) => {
|
|
202
|
-
if (!count(chunk))
|
|
203
|
-
return;
|
|
204
|
-
pending += decoder.write(chunk);
|
|
205
|
-
let newline;
|
|
206
|
-
while ((newline = pending.indexOf("\n")) >= 0) {
|
|
207
|
-
const line = pending.slice(0, newline);
|
|
208
|
-
pending = pending.slice(newline + 1);
|
|
209
|
-
lineReceived(line);
|
|
283
|
+
const cleanupWarning = "Temporary working-directory cleanup did not complete.";
|
|
284
|
+
if (turnError instanceof Error)
|
|
285
|
+
turnError.message = `${turnError.message} ${cleanupWarning}`;
|
|
286
|
+
else
|
|
287
|
+
throw new Error(`${definition.label} completed, but temporary working-directory cleanup did not complete.`);
|
|
210
288
|
}
|
|
211
|
-
});
|
|
212
|
-
child.once("error", (error) => {
|
|
213
|
-
finish(new Error(error.code === "ENOENT" ? `${definition.command} is not installed or is not on PATH.` : `${definition.label} failed to start.`));
|
|
214
|
-
});
|
|
215
|
-
child.once("close", (code) => {
|
|
216
|
-
if (failure)
|
|
217
|
-
return finish(failure);
|
|
218
|
-
if (timedOut)
|
|
219
|
-
return finish(new Error(`${definition.label} timed out.`));
|
|
220
|
-
if (context.signal.aborted)
|
|
221
|
-
return finish(new Error(`${definition.label} was cancelled.`));
|
|
222
|
-
if (code !== 0)
|
|
223
|
-
return finish(new Error(`${definition.label} exited with code ${code ?? "unknown"}. Check the client's sign-in, subscription, and Cozibud MCP authorization.`));
|
|
224
|
-
lineReceived(pending + decoder.end());
|
|
225
|
-
if (failure)
|
|
226
|
-
return finish(failure);
|
|
227
|
-
if (!response.trim())
|
|
228
|
-
return finish(new Error(`${definition.label} returned no assistant response.`));
|
|
229
|
-
finish();
|
|
230
|
-
});
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
catch (error) {
|
|
234
|
-
turnError = error;
|
|
235
|
-
throw error;
|
|
236
|
-
}
|
|
237
|
-
finally {
|
|
238
|
-
if (ephemeralCwd) {
|
|
239
|
-
try {
|
|
240
|
-
await (options.removeDirectory ?? rm)(ephemeralCwd, TEMP_DIRECTORY_CLEANUP_OPTIONS);
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
const cleanupWarning = "Temporary working-directory cleanup did not complete.";
|
|
244
|
-
if (turnError instanceof Error)
|
|
245
|
-
turnError.message = `${turnError.message} ${cleanupWarning}`;
|
|
246
|
-
else
|
|
247
|
-
throw new Error(`${definition.label} completed, but temporary working-directory cleanup did not complete.`);
|
|
248
289
|
}
|
|
249
290
|
}
|
|
250
291
|
}
|
|
292
|
+
finally {
|
|
293
|
+
releaseCursor?.();
|
|
294
|
+
}
|
|
251
295
|
},
|
|
252
296
|
};
|
|
253
297
|
}
|
|
@@ -263,17 +307,46 @@ export function executable(configured) {
|
|
|
263
307
|
for (const directory of directories) {
|
|
264
308
|
const base = directory ? join(directory, configured) : configured;
|
|
265
309
|
const script = /\.ps1$/i.test(base) ? base : `${base}.ps1`;
|
|
266
|
-
if (existsSync(script))
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
310
|
+
if (!existsSync(script))
|
|
311
|
+
continue;
|
|
312
|
+
const shimmed = nodeShimTarget(script);
|
|
313
|
+
if (shimmed)
|
|
314
|
+
return shimmed;
|
|
315
|
+
return {
|
|
316
|
+
command: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe"),
|
|
317
|
+
prefixArgs: ["-NoLogo", "-NoProfile", "-File", script],
|
|
318
|
+
};
|
|
271
319
|
}
|
|
272
320
|
}
|
|
273
321
|
return /\.(?:c|m)?js$/i.test(configured)
|
|
274
322
|
? { command: process.execPath, prefixArgs: [configured] }
|
|
275
323
|
: { command: configured, prefixArgs: [] };
|
|
276
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* npm, pnpm and Yarn install a CLI on Windows as a PowerShell shim that runs a JavaScript entry point
|
|
327
|
+
* with Node. Running that entry point directly keeps arguments intact: `powershell -File` treats a bare
|
|
328
|
+
* `-` (read the prompt from stdin) as an unnamed parameter and fails before the CLI starts.
|
|
329
|
+
*/
|
|
330
|
+
function nodeShimTarget(script) {
|
|
331
|
+
let source;
|
|
332
|
+
try {
|
|
333
|
+
source = readFileSync(script, "utf8");
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
if (source.length > 16_384)
|
|
339
|
+
return null;
|
|
340
|
+
const target = /"\$basedir[\\/]([^"$`]+\.[cm]?js)"/i.exec(source)?.[1];
|
|
341
|
+
if (!target)
|
|
342
|
+
return null;
|
|
343
|
+
const basedir = dirname(script);
|
|
344
|
+
const entry = resolve(basedir, target);
|
|
345
|
+
if (!existsSync(entry))
|
|
346
|
+
return null;
|
|
347
|
+
const bundledNode = join(basedir, "node.exe");
|
|
348
|
+
return { command: existsSync(bundledNode) ? bundledNode : process.execPath, prefixArgs: [entry] };
|
|
349
|
+
}
|
|
277
350
|
const codexExecutable = executable(process.env.COZIBUD_CODEX_PATH || "codex");
|
|
278
351
|
const claudeExecutable = executable(process.env.COZIBUD_CLAUDE_PATH || "claude");
|
|
279
352
|
const geminiExecutable = executable(process.env.COZIBUD_GEMINI_PATH || "gemini");
|
|
@@ -284,7 +357,8 @@ export const builtInProcessHarnesses = [
|
|
|
284
357
|
label: "Codex CLI",
|
|
285
358
|
command: codexExecutable.command,
|
|
286
359
|
versionArgs: [...codexExecutable.prefixArgs, "--version"],
|
|
287
|
-
args: () => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only",
|
|
360
|
+
args: (input) => [...codexExecutable.prefixArgs, "exec", "--json", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only",
|
|
361
|
+
...(input?.interpretation ? ["--ignore-user-config", "--ignore-rules", "-c", "features.shell_tool=false", "-c", "features.apps=false", "-c", "features.multi_agent=false", "-c", "features.hooks=false", "-c", "web_search=\"disabled\""] : ["-c", "mcp_servers.cozibud.required=true"]), "-"],
|
|
288
362
|
},
|
|
289
363
|
{
|
|
290
364
|
id: "claude",
|
|
@@ -305,9 +379,13 @@ export const builtInProcessHarnesses = [
|
|
|
305
379
|
id: "cursor",
|
|
306
380
|
label: "Cursor Agent",
|
|
307
381
|
command: cursorExecutable.command,
|
|
308
|
-
emptyDirectoryArgs: ["--trust"],
|
|
309
382
|
versionArgs: [...cursorExecutable.prefixArgs, "--version"],
|
|
310
|
-
args: () => [
|
|
383
|
+
args: () => [
|
|
384
|
+
...cursorExecutable.prefixArgs,
|
|
385
|
+
"-p", "--output-format", "stream-json", "--mode", "ask",
|
|
386
|
+
...(process.platform === "win32" ? [] : ["--sandbox", "enabled"]),
|
|
387
|
+
"--trust",
|
|
388
|
+
],
|
|
311
389
|
},
|
|
312
390
|
];
|
|
313
391
|
export function createBuiltInDrivers() {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { HarnessDriver } from "./contracts.js";
|
|
2
|
+
export type StoredExecution = {
|
|
3
|
+
state: "started";
|
|
4
|
+
updatedAt: string;
|
|
5
|
+
} | {
|
|
6
|
+
state: "completed";
|
|
7
|
+
modelOutput: string;
|
|
8
|
+
updatedAt: string;
|
|
9
|
+
} | {
|
|
10
|
+
state: "failed" | "cancelled";
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
};
|
|
15
|
+
export interface RelayExecutionStore {
|
|
16
|
+
get(requestId: string): Promise<StoredExecution | null>;
|
|
17
|
+
set(requestId: string, value: StoredExecution): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare class MemoryRelayExecutionStore implements RelayExecutionStore {
|
|
20
|
+
private readonly values;
|
|
21
|
+
get(id: string): Promise<StoredExecution | null>;
|
|
22
|
+
set(id: string, value: StoredExecution): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
/** Privacy-bounded local journal: request ids and terminal results only, never prompts or credentials. */
|
|
25
|
+
export declare class FileRelayExecutionStore implements RelayExecutionStore {
|
|
26
|
+
private readonly path;
|
|
27
|
+
private readonly maxEntries;
|
|
28
|
+
private readonly retentionMs;
|
|
29
|
+
private writeQueue;
|
|
30
|
+
constructor(path: string, maxEntries?: number, retentionMs?: number);
|
|
31
|
+
private read;
|
|
32
|
+
get(id: string): Promise<StoredExecution | null>;
|
|
33
|
+
set(id: string, value: StoredExecution): Promise<void>;
|
|
34
|
+
private write;
|
|
35
|
+
}
|
|
36
|
+
type Fetch = typeof fetch;
|
|
37
|
+
export declare class RelayTransportError extends Error {
|
|
38
|
+
readonly status: number;
|
|
39
|
+
readonly code: string;
|
|
40
|
+
constructor(status: number, code: string);
|
|
41
|
+
}
|
|
42
|
+
export declare class OutboundRelayBridge {
|
|
43
|
+
private readonly options;
|
|
44
|
+
readonly connectionId: `${string}-${string}-${string}-${string}-${string}`;
|
|
45
|
+
private readonly active;
|
|
46
|
+
private readonly tasks;
|
|
47
|
+
private readonly scheduled;
|
|
48
|
+
private readonly cancelled;
|
|
49
|
+
constructor(options: {
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
credential: string;
|
|
52
|
+
driver: HarnessDriver;
|
|
53
|
+
store: RelayExecutionStore;
|
|
54
|
+
fetchImpl?: Fetch;
|
|
55
|
+
maxActive?: number;
|
|
56
|
+
});
|
|
57
|
+
private get fetcher();
|
|
58
|
+
private headers;
|
|
59
|
+
private json;
|
|
60
|
+
connect(): Promise<unknown>;
|
|
61
|
+
step(): Promise<"idle" | "command" | "cancel">;
|
|
62
|
+
idle(): Promise<void>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
private acknowledge;
|
|
65
|
+
private result;
|
|
66
|
+
private fromStored;
|
|
67
|
+
private handle;
|
|
68
|
+
}
|
|
69
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { WATCH_RELAY_PROTOCOL_VERSION } from "./relay-protocol.js";
|
|
5
|
+
export class MemoryRelayExecutionStore {
|
|
6
|
+
values = new Map();
|
|
7
|
+
async get(id) { return this.values.get(id) ?? null; }
|
|
8
|
+
async set(id, value) { this.values.set(id, value); }
|
|
9
|
+
}
|
|
10
|
+
/** Privacy-bounded local journal: request ids and terminal results only, never prompts or credentials. */
|
|
11
|
+
export class FileRelayExecutionStore {
|
|
12
|
+
path;
|
|
13
|
+
maxEntries;
|
|
14
|
+
retentionMs;
|
|
15
|
+
writeQueue = Promise.resolve();
|
|
16
|
+
constructor(path, maxEntries = 100, retentionMs = 24 * 60 * 60_000) {
|
|
17
|
+
this.path = path;
|
|
18
|
+
this.maxEntries = maxEntries;
|
|
19
|
+
this.retentionMs = retentionMs;
|
|
20
|
+
}
|
|
21
|
+
async read() {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(await readFile(this.path, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === "ENOENT")
|
|
27
|
+
return {};
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async get(id) { await this.writeQueue; return (await this.read())[id] ?? null; }
|
|
32
|
+
async set(id, value) {
|
|
33
|
+
const write = this.writeQueue.then(() => this.write(id, value));
|
|
34
|
+
this.writeQueue = write.catch(() => { });
|
|
35
|
+
await write;
|
|
36
|
+
}
|
|
37
|
+
async write(id, value) {
|
|
38
|
+
const values = await this.read();
|
|
39
|
+
values[id] = value;
|
|
40
|
+
const cutoff = Date.now() - this.retentionMs;
|
|
41
|
+
const retained = Object.entries(values).filter(([, row]) => Date.parse(row.updatedAt) >= cutoff)
|
|
42
|
+
.sort((a, b) => Date.parse(b[1].updatedAt) - Date.parse(a[1].updatedAt)).slice(0, this.maxEntries);
|
|
43
|
+
await mkdir(dirname(this.path), { recursive: true });
|
|
44
|
+
const temporary = `${this.path}.${process.pid}.tmp`;
|
|
45
|
+
await writeFile(temporary, JSON.stringify(Object.fromEntries(retained)), { encoding: "utf8", mode: 0o600 });
|
|
46
|
+
await rename(temporary, this.path);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class RelayTransportError extends Error {
|
|
50
|
+
status;
|
|
51
|
+
code;
|
|
52
|
+
constructor(status, code) {
|
|
53
|
+
super("Cozibud relay request failed.");
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class OutboundRelayBridge {
|
|
59
|
+
options;
|
|
60
|
+
connectionId = randomUUID();
|
|
61
|
+
active = new Map();
|
|
62
|
+
tasks = new Set();
|
|
63
|
+
scheduled = new Set();
|
|
64
|
+
cancelled = new Set();
|
|
65
|
+
constructor(options) {
|
|
66
|
+
this.options = options;
|
|
67
|
+
}
|
|
68
|
+
get fetcher() { return this.options.fetchImpl ?? fetch; }
|
|
69
|
+
headers(extra) { return { authorization: `Bearer ${this.options.credential}`, ...extra }; }
|
|
70
|
+
async json(path, init) {
|
|
71
|
+
const response = await this.fetcher(new URL(path, this.options.baseUrl), { ...init, redirect: "error", signal: AbortSignal.timeout(20_000) });
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
const code = await response.json().then(value => value.error?.code).catch(() => undefined);
|
|
74
|
+
throw new RelayTransportError(response.status, code ?? `relay_http_${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
return response.status === 204 ? null : response.json();
|
|
77
|
+
}
|
|
78
|
+
async connect() {
|
|
79
|
+
return this.json("/api/watch-relay/v1/connect", { method: "POST", headers: this.headers({ "content-type": "application/json" }),
|
|
80
|
+
body: JSON.stringify({ connectionId: this.connectionId, protocolVersion: WATCH_RELAY_PROTOCOL_VERSION }) });
|
|
81
|
+
}
|
|
82
|
+
async step() {
|
|
83
|
+
const full = this.scheduled.size >= (this.options.maxActive ?? 2);
|
|
84
|
+
const delivery = await this.json(`/api/watch-relay/v1/deliveries/next${full ? "?cancelOnly=1" : ""}`, { method: "GET",
|
|
85
|
+
headers: this.headers({ "x-cozibud-connection": this.connectionId }) });
|
|
86
|
+
if (!delivery)
|
|
87
|
+
return "idle";
|
|
88
|
+
if (delivery.type === "cancel") {
|
|
89
|
+
this.cancelled.add(delivery.requestId);
|
|
90
|
+
this.active.get(delivery.requestId)?.abort();
|
|
91
|
+
return "cancel";
|
|
92
|
+
}
|
|
93
|
+
if (this.scheduled.has(delivery.command.requestId)) {
|
|
94
|
+
await this.acknowledge(delivery.command);
|
|
95
|
+
return "command";
|
|
96
|
+
}
|
|
97
|
+
this.scheduled.add(delivery.command.requestId);
|
|
98
|
+
// A transport loss after the local terminal result is journaled is
|
|
99
|
+
// recovered by stable-id redelivery; do not turn it into an unhandled
|
|
100
|
+
// rejection or execute the provider again.
|
|
101
|
+
const task = this.handle(delivery.command).catch(() => { }).finally(() => { this.scheduled.delete(delivery.command.requestId); this.tasks.delete(task); });
|
|
102
|
+
this.tasks.add(task);
|
|
103
|
+
return "command";
|
|
104
|
+
}
|
|
105
|
+
async idle() { await Promise.all(this.tasks); }
|
|
106
|
+
async close() { for (const controller of this.active.values())
|
|
107
|
+
controller.abort(); await this.idle(); }
|
|
108
|
+
async acknowledge(command) {
|
|
109
|
+
await this.json(`/api/watch-relay/v1/deliveries/${command.deliveryId}/ack`, { method: "POST", headers: this.headers({ "content-type": "application/json" }),
|
|
110
|
+
body: JSON.stringify({ requestId: command.requestId, connectionId: this.connectionId }) });
|
|
111
|
+
}
|
|
112
|
+
async result(command, result) {
|
|
113
|
+
await this.json(`/api/watch-relay/v1/deliveries/${command.deliveryId}/result`, { method: "POST", headers: this.headers({ "content-type": "application/json" }),
|
|
114
|
+
body: JSON.stringify({ requestId: command.requestId, connectionId: this.connectionId, ...result }) });
|
|
115
|
+
}
|
|
116
|
+
fromStored(value) {
|
|
117
|
+
if (value.state === "completed")
|
|
118
|
+
return { state: "completed", modelOutput: value.modelOutput };
|
|
119
|
+
if (value.state === "started")
|
|
120
|
+
return { state: "failed", error: { code: "execution_interrupted", message: "The prior local execution ended without a durable result; it was not repeated." } };
|
|
121
|
+
return { state: value.state, error: { code: value.code, message: value.message } };
|
|
122
|
+
}
|
|
123
|
+
async handle(command) {
|
|
124
|
+
if (this.active.has(command.requestId)) {
|
|
125
|
+
await this.acknowledge(command);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const stored = await this.options.store.get(command.requestId);
|
|
129
|
+
if (stored) {
|
|
130
|
+
await this.acknowledge(command);
|
|
131
|
+
await this.result(command, this.fromStored(stored));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await this.acknowledge(command);
|
|
135
|
+
await this.options.store.set(command.requestId, { state: "started", updatedAt: new Date().toISOString() });
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
this.active.set(command.requestId, controller);
|
|
138
|
+
if (this.cancelled.delete(command.requestId))
|
|
139
|
+
controller.abort();
|
|
140
|
+
let terminal;
|
|
141
|
+
if (controller.signal.aborted) {
|
|
142
|
+
terminal = { state: "cancelled", code: "cancelled", message: "The request was cancelled.", updatedAt: new Date().toISOString() };
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
try {
|
|
146
|
+
const prompt = command.transcript ?? JSON.stringify(command.action);
|
|
147
|
+
const source = command.source === "structured-action" ? "quick-action" : command.source;
|
|
148
|
+
const output = await this.options.driver.run({ prompt, source, client: command.client,
|
|
149
|
+
interpretation: command.interpretation,
|
|
150
|
+
careRecordIdempotencyKey: `relay:${command.requestId}` }, {
|
|
151
|
+
turnId: command.requestId, signal: controller.signal,
|
|
152
|
+
// Progress remains local in v1; bounded server progress is reserved by the protocol.
|
|
153
|
+
emit: () => { },
|
|
154
|
+
});
|
|
155
|
+
terminal = { state: "completed", modelOutput: output.text, updatedAt: new Date().toISOString() };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const cancelled = controller.signal.aborted;
|
|
159
|
+
terminal = { state: cancelled ? "cancelled" : "failed", code: cancelled ? "cancelled" : "provider_failed",
|
|
160
|
+
message: cancelled ? "The request was cancelled." : (error instanceof Error ? error.message.slice(0, 512) : "The local provider failed."), updatedAt: new Date().toISOString() };
|
|
161
|
+
}
|
|
162
|
+
this.active.delete(command.requestId);
|
|
163
|
+
await this.options.store.set(command.requestId, terminal);
|
|
164
|
+
await this.result(command, this.fromStored(terminal));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Versioned wire values consumed by the outbound Cozibud relay client. */
|
|
2
|
+
export declare const WATCH_RELAY_PROTOCOL: "cozibud.watch-relay.v2";
|
|
3
|
+
export declare const WATCH_RELAY_PROTOCOL_VERSION: 2;
|
|
4
|
+
export type WatchRelayCommand = Readonly<{
|
|
5
|
+
protocol: typeof WATCH_RELAY_PROTOCOL;
|
|
6
|
+
requestId: string;
|
|
7
|
+
deliveryId: string;
|
|
8
|
+
attempt: number;
|
|
9
|
+
source: "typed" | "voice-transcript" | "structured-action";
|
|
10
|
+
client: "web" | "phone" | "watch";
|
|
11
|
+
transcript?: string;
|
|
12
|
+
action?: Readonly<{
|
|
13
|
+
id: string;
|
|
14
|
+
arguments: Readonly<Record<string, unknown>>;
|
|
15
|
+
}>;
|
|
16
|
+
expiresAt: string;
|
|
17
|
+
interpretation?: string;
|
|
18
|
+
}>;
|