@turns/dev 0.0.30 → 0.0.31
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/dist/harness-exit.js +1 -1
- package/dist/harness-update.js +236 -0
- package/dist/path-resolution.js +5 -1
- package/dist/queue-worker.js +52 -20
- package/dist/upload-sessions.js +57 -7
- package/package.json +1 -1
- package/dist/codex-update.js +0 -162
package/dist/harness-exit.js
CHANGED
|
@@ -38,7 +38,7 @@ export function harnessExitError(harness, code, signal, stderr) {
|
|
|
38
38
|
harnessExit: { harness: String(harness), code: code ?? null, signal: signal ?? null, stderr },
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
|
-
function harnessLabel(harness) {
|
|
41
|
+
export function harnessLabel(harness) {
|
|
42
42
|
const name = String(harness || 'agent');
|
|
43
43
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
44
44
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/** Machine-local harness CLI upgrades, callable by the Turns daemon. */
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { executablePath } from "./executable-path.js";
|
|
7
|
+
import { harnessLabel } from "./harness-exit.js";
|
|
8
|
+
import { ACP_PROFILES, codexCommandIsAppManaged, HARNESS_IDS, RPC_PROFILES, resolveHarnessCommand, } from "./harness-registry.js";
|
|
9
|
+
const VERSION_TIMEOUT_MS = 15_000;
|
|
10
|
+
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
11
|
+
const OUTPUT_LIMIT = 64 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Native installers whose CLI updates itself without prompting. Each entry was
|
|
14
|
+
* checked against the CLI's own `--help`; a harness absent here is upgradable
|
|
15
|
+
* only when a package manager owns its executable. Some updaters ask before
|
|
16
|
+
* installing (Devin) or rewrite a source checkout (Hermes), so they are left
|
|
17
|
+
* out rather than run unattended.
|
|
18
|
+
*/
|
|
19
|
+
const SELF_UPDATE_ARGS = {
|
|
20
|
+
claude: ['update'],
|
|
21
|
+
codex: ['update'],
|
|
22
|
+
cursor: ['update'],
|
|
23
|
+
antigravity: ['update'],
|
|
24
|
+
omp: ['update'],
|
|
25
|
+
grok: ['update'],
|
|
26
|
+
goose: ['update'],
|
|
27
|
+
fx: ['upgrade'],
|
|
28
|
+
kimi: ['upgrade'],
|
|
29
|
+
opencode: ['upgrade'],
|
|
30
|
+
kilo: ['upgrade'],
|
|
31
|
+
};
|
|
32
|
+
/** Run the resolved executable directly—never through a shell—so update
|
|
33
|
+
* requests cannot become arbitrary command execution. */
|
|
34
|
+
function runCommand(command, args, timeoutMs) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const child = spawn(command, args, {
|
|
37
|
+
cwd: os.homedir(),
|
|
38
|
+
// npm and npm-installed CLIs are `#!/usr/bin/env node` scripts; a
|
|
39
|
+
// service manager's PATH may not include the daemon's own Node.
|
|
40
|
+
env: { ...process.env, PATH: executablePath() },
|
|
41
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
42
|
+
});
|
|
43
|
+
let output = '';
|
|
44
|
+
let settled = false;
|
|
45
|
+
const settle = (result) => {
|
|
46
|
+
if (settled)
|
|
47
|
+
return;
|
|
48
|
+
settled = true;
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
resolve(result);
|
|
51
|
+
};
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
try {
|
|
54
|
+
child.kill('SIGKILL');
|
|
55
|
+
}
|
|
56
|
+
catch { }
|
|
57
|
+
settle({ output, exitCode: null, timedOut: true });
|
|
58
|
+
}, timeoutMs);
|
|
59
|
+
timer.unref?.();
|
|
60
|
+
const collect = (chunk) => {
|
|
61
|
+
if (output.length < OUTPUT_LIMIT)
|
|
62
|
+
output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
|
|
63
|
+
};
|
|
64
|
+
child.stdout.on('data', collect);
|
|
65
|
+
child.stderr.on('data', collect);
|
|
66
|
+
child.on('error', () => settle({ output, exitCode: null, failed: true }));
|
|
67
|
+
child.on('close', (exitCode) => settle({ output, exitCode }));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** The first dotted version in a CLI's `--version` output, whatever its
|
|
71
|
+
* prefix (`codex-cli 0.164.0`, `omp/18.0.4`, `GitHub Copilot CLI 1.0.80.`). */
|
|
72
|
+
export function parseHarnessVersion(output) {
|
|
73
|
+
const text = String(output || '').replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '');
|
|
74
|
+
const version = text.match(/\d+(?:\.\d+)+(?:-[0-9A-Za-z.]+)?/u)?.[0] || '';
|
|
75
|
+
return version.replace(/\.$/u, '').slice(0, 120);
|
|
76
|
+
}
|
|
77
|
+
function commandFailure(result, label, fallback) {
|
|
78
|
+
if (result.timedOut)
|
|
79
|
+
return new Error(`The ${label} update timed out.`);
|
|
80
|
+
const lines = result.output
|
|
81
|
+
.replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((line) => line.trim())
|
|
84
|
+
// npm ends every failure by pointing at its debug log.
|
|
85
|
+
.filter((line) => line && !/A complete log of this run can be found/iu.test(line));
|
|
86
|
+
return new Error(lines.at(-1) || fallback);
|
|
87
|
+
}
|
|
88
|
+
function realpathOrSelf(file) {
|
|
89
|
+
try {
|
|
90
|
+
return fs.realpathSync(file);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return file;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function findExecutable(name, preferred) {
|
|
97
|
+
for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
|
|
98
|
+
const candidate = path.join(directory, name);
|
|
99
|
+
try {
|
|
100
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
101
|
+
return candidate;
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
}
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
export function harnessUpgradeCommand(harness, command, lookup = {}) {
|
|
108
|
+
const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
|
|
109
|
+
const find = lookup.findExecutable || findExecutable;
|
|
110
|
+
const platform = lookup.platform || process.platform;
|
|
111
|
+
// `npm i -g <pkg>` links <prefix>/bin/<cli> into <prefix>/lib/node_modules.
|
|
112
|
+
// Requiring the `lib/` layout keeps pnpm and Bun global stores, which also
|
|
113
|
+
// contain node_modules, away from an npm that would install a second copy.
|
|
114
|
+
// Windows npm is a .cmd shim, which cannot be spawned without a shell.
|
|
115
|
+
const npm = resolved.match(/^(.+?)\/lib\/node_modules\/((?:@[^/]+\/)?[^/@][^/]*)\//u);
|
|
116
|
+
if (npm && platform !== 'win32') {
|
|
117
|
+
const [, prefix, name] = npm;
|
|
118
|
+
const binary = find('npm', [path.join(prefix, 'bin')]);
|
|
119
|
+
if (binary) {
|
|
120
|
+
return {
|
|
121
|
+
manager: 'npm',
|
|
122
|
+
command: binary,
|
|
123
|
+
args: ['install', '-g', '--prefix', prefix, `${name}@latest`],
|
|
124
|
+
check: {
|
|
125
|
+
args: ['view', name, 'version'],
|
|
126
|
+
failure: `${name} is not published on the npm registry, so this installation must be updated on the machine.`,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const brew = resolved.match(/^(.+?)\/(Cellar|Caskroom)\/([^/]+)\//u);
|
|
132
|
+
if (brew) {
|
|
133
|
+
const binary = find('brew', [path.join(brew[1], 'bin')]);
|
|
134
|
+
if (binary)
|
|
135
|
+
return {
|
|
136
|
+
manager: 'brew',
|
|
137
|
+
command: binary,
|
|
138
|
+
args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), brew[3]],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
const uv = resolved.match(/\/uv\/tools\/([^/]+)\//u);
|
|
142
|
+
if (uv) {
|
|
143
|
+
const binary = find('uv', []);
|
|
144
|
+
if (binary)
|
|
145
|
+
return { manager: 'uv', command: binary, args: ['tool', 'upgrade', uv[1]] };
|
|
146
|
+
}
|
|
147
|
+
const pipx = resolved.match(/\/pipx\/venvs\/([^/]+)\//u);
|
|
148
|
+
if (pipx) {
|
|
149
|
+
const binary = find('pipx', []);
|
|
150
|
+
if (binary)
|
|
151
|
+
return { manager: 'pipx', command: binary, args: ['upgrade', pipx[1]] };
|
|
152
|
+
}
|
|
153
|
+
const self = SELF_UPDATE_ARGS[harness];
|
|
154
|
+
return self ? { manager: 'self', command, args: self } : null;
|
|
155
|
+
}
|
|
156
|
+
/** The executable the agent anchor launches for a harness. */
|
|
157
|
+
function harnessExecutable(harness) {
|
|
158
|
+
return resolveHarnessCommand(harness, ACP_PROFILES[harness] || RPC_PROFILES[harness] || {});
|
|
159
|
+
}
|
|
160
|
+
export function createHarnessUpdateManager(dependencies = {}) {
|
|
161
|
+
const harnesses = dependencies.harnesses || HARNESS_IDS;
|
|
162
|
+
const resolveCommand = dependencies.resolveCommand || harnessExecutable;
|
|
163
|
+
const isAppManaged = dependencies.isAppManaged ||
|
|
164
|
+
((harness, command) => harness === 'codex' && codexCommandIsAppManaged(command));
|
|
165
|
+
const upgradeCommand = dependencies.upgradeCommand || ((harness, command) => harnessUpgradeCommand(harness, command));
|
|
166
|
+
const run = dependencies.run || runCommand;
|
|
167
|
+
const active = new Map();
|
|
168
|
+
const plan = (harness) => {
|
|
169
|
+
const label = harnessLabel(harness);
|
|
170
|
+
if (!harnesses.includes(harness))
|
|
171
|
+
throw new Error(`Turns cannot update ${label}.`);
|
|
172
|
+
const command = resolveCommand(harness);
|
|
173
|
+
if (isAppManaged(harness, command)) {
|
|
174
|
+
throw new Error(`${label} is managed by the macOS app. Update the ${label} app to update its bundled CLI.`);
|
|
175
|
+
}
|
|
176
|
+
const upgrade = upgradeCommand(harness, command);
|
|
177
|
+
if (!upgrade)
|
|
178
|
+
throw new Error(`Turns does not know how to update this ${label} installation. Update it on the machine.`);
|
|
179
|
+
return { label, command, upgrade };
|
|
180
|
+
};
|
|
181
|
+
const readVersion = async (command, label, fallback) => {
|
|
182
|
+
const result = await run(command, ['--version'], VERSION_TIMEOUT_MS);
|
|
183
|
+
if (result.failed || result.timedOut || result.exitCode !== 0)
|
|
184
|
+
throw commandFailure(result, label, fallback);
|
|
185
|
+
return parseHarnessVersion(result.output);
|
|
186
|
+
};
|
|
187
|
+
const perform = async (harness) => {
|
|
188
|
+
const { label, command, upgrade } = plan(harness);
|
|
189
|
+
const previousVersion = await readVersion(command, label, `${label} is not installed or its version could not be read.`);
|
|
190
|
+
if (upgrade.check) {
|
|
191
|
+
const checked = await run(upgrade.command, upgrade.check.args, VERSION_TIMEOUT_MS);
|
|
192
|
+
if (checked.timedOut)
|
|
193
|
+
throw new Error(`The ${label} update timed out.`);
|
|
194
|
+
if (checked.failed || checked.exitCode !== 0)
|
|
195
|
+
throw new Error(upgrade.check.failure);
|
|
196
|
+
}
|
|
197
|
+
const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
|
|
198
|
+
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
199
|
+
const manual = [path.basename(upgrade.command), ...upgrade.args].join(' ');
|
|
200
|
+
throw commandFailure(updateRun, label, upgrade.manager === 'self'
|
|
201
|
+
? `${label} could not update itself. Run \`${manual}\` in a terminal on the machine.`
|
|
202
|
+
: `${upgrade.manager} could not upgrade ${label}. Run \`${manual}\` in a terminal on the machine.`);
|
|
203
|
+
}
|
|
204
|
+
// Resolve again because the updater may replace a shim or install path.
|
|
205
|
+
const currentVersion = await readVersion(resolveCommand(harness), label, `${label} updated, but its new version could not be read.`);
|
|
206
|
+
return {
|
|
207
|
+
...(previousVersion ? { previousVersion } : {}),
|
|
208
|
+
...(currentVersion ? { currentVersion } : {}),
|
|
209
|
+
updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
|
|
210
|
+
};
|
|
211
|
+
};
|
|
212
|
+
return {
|
|
213
|
+
/** Harnesses installed here in a way this daemon can upgrade. */
|
|
214
|
+
available() {
|
|
215
|
+
return harnesses.filter((harness) => {
|
|
216
|
+
try {
|
|
217
|
+
plan(harness);
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
/** Concurrent clicks for one harness share one installer process instead
|
|
226
|
+
* of racing writes to the same installation. */
|
|
227
|
+
update(harness) {
|
|
228
|
+
let pending = active.get(harness);
|
|
229
|
+
if (!pending) {
|
|
230
|
+
pending = perform(harness).finally(() => active.delete(harness));
|
|
231
|
+
active.set(harness, pending);
|
|
232
|
+
}
|
|
233
|
+
return pending;
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
package/dist/path-resolution.js
CHANGED
|
@@ -87,7 +87,11 @@ export function pathCandidates(row) {
|
|
|
87
87
|
continue;
|
|
88
88
|
if (found.includes(candidate))
|
|
89
89
|
continue;
|
|
90
|
-
|
|
90
|
+
// A regex match is a slice of the row it came from, and a slice keeps the
|
|
91
|
+
// whole row alive for as long as it is remembered. The resolver remembers
|
|
92
|
+
// candidates per session for the life of the daemon, so each one has to
|
|
93
|
+
// be its own small string rather than a window onto kilobytes of transcript.
|
|
94
|
+
found.push(Buffer.from(candidate).toString());
|
|
91
95
|
if (found.length >= MAX_PATH_CANDIDATES_PER_ROW)
|
|
92
96
|
break;
|
|
93
97
|
}
|
package/dist/queue-worker.js
CHANGED
|
@@ -7,7 +7,7 @@ import { pipeline } from 'node:stream/promises';
|
|
|
7
7
|
import { logError } from "./logging.js";
|
|
8
8
|
import { QueueDispatchLifecycle, QUEUE_RESTART_MESSAGE } from "./queue-lifecycle.js";
|
|
9
9
|
import { anchorId, connectExistingAgentAnchors, launchAgentAnchor, waitForAgentTurn } from "./agent-anchor-client.js";
|
|
10
|
-
import {
|
|
10
|
+
import { createHarnessUpdateManager } from "./harness-update.js";
|
|
11
11
|
import { isAsyncQuestionId } from "./codex-async-question-recovery.js";
|
|
12
12
|
import { assertAllowedWorkingDirectory, workingDirectoryIsAllowed } from "./directory-policy.js";
|
|
13
13
|
import { browseMachinePath, streamMachineFileRange } from "./file-browsing.js";
|
|
@@ -341,6 +341,14 @@ export function runtimeEvent(anchorName, messageId, event, redactor = DEFAULT_RE
|
|
|
341
341
|
}
|
|
342
342
|
return null;
|
|
343
343
|
}
|
|
344
|
+
/** Registration capabilities for harness upgrades. `codexUpdate` remains for
|
|
345
|
+
* backends that only know how to route Codex upgrades. */
|
|
346
|
+
export function updateCapabilities(harnesses) {
|
|
347
|
+
return {
|
|
348
|
+
...(harnesses.length ? { harnessUpdates: harnesses } : {}),
|
|
349
|
+
...(harnesses.includes('codex') ? { codexUpdate: true } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
344
352
|
export function resultError(result) {
|
|
345
353
|
if (!result)
|
|
346
354
|
return new Error('The anchored agent returned no result.');
|
|
@@ -1339,18 +1347,22 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1339
1347
|
});
|
|
1340
1348
|
}
|
|
1341
1349
|
};
|
|
1342
|
-
/** Upgrade the machine's
|
|
1343
|
-
*
|
|
1344
|
-
*
|
|
1345
|
-
|
|
1346
|
-
|
|
1350
|
+
/** Upgrade one of the machine's harness CLIs. Only a fixed command chosen
|
|
1351
|
+
* from the resolved executable's install location is run; the browser names
|
|
1352
|
+
* a harness but cannot supply a binary, arguments, or shell input. Errors
|
|
1353
|
+
* are redacted before crossing the machine boundary. The message keeps its
|
|
1354
|
+
* Codex-era name; a request without a harness is from a Codex-only backend. */
|
|
1355
|
+
const harnessUpdate = createHarnessUpdateManager();
|
|
1356
|
+
const handleHarnessUpdate = async (message, replyRpc) => {
|
|
1347
1357
|
const sessionId = String(message.sessionId || '');
|
|
1358
|
+
const harness = String(message.harness || 'codex');
|
|
1348
1359
|
try {
|
|
1349
|
-
const answer = await
|
|
1360
|
+
const answer = await harnessUpdate.update(harness);
|
|
1350
1361
|
replyRpc.notify({
|
|
1351
1362
|
type: 'session.codex.update.result',
|
|
1352
1363
|
requestId: message.requestId,
|
|
1353
1364
|
sessionId,
|
|
1365
|
+
harness,
|
|
1354
1366
|
ok: true,
|
|
1355
1367
|
answer,
|
|
1356
1368
|
});
|
|
@@ -1360,6 +1372,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1360
1372
|
type: 'session.codex.update.result',
|
|
1361
1373
|
requestId: message.requestId,
|
|
1362
1374
|
sessionId,
|
|
1375
|
+
harness,
|
|
1363
1376
|
ok: false,
|
|
1364
1377
|
error: redactor.redactText(error?.message || String(error)),
|
|
1365
1378
|
});
|
|
@@ -1424,33 +1437,44 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1424
1437
|
}
|
|
1425
1438
|
}, HARNESS_HEARTBEAT_MS);
|
|
1426
1439
|
heartbeat.unref?.();
|
|
1440
|
+
// A browser request is answered over the same socket it arrived on. When
|
|
1441
|
+
// that socket drops mid-request, the handler's reply throws, and so does the
|
|
1442
|
+
// error reply in its catch block. Nothing awaits these handlers, so that
|
|
1443
|
+
// second throw used to surface as an unhandled rejection and take the whole
|
|
1444
|
+
// daemon down with every running session's watcher. The browser has already
|
|
1445
|
+
// been told its connection closed; there is nobody left to answer.
|
|
1446
|
+
const answer = (handler, message) => {
|
|
1447
|
+
void Promise.resolve()
|
|
1448
|
+
.then(() => handler(message, rpc))
|
|
1449
|
+
.catch((error) => logError(`Could not answer ${message.type}: ${error?.message || error}\n`));
|
|
1450
|
+
};
|
|
1427
1451
|
rpc.onEvent((message) => {
|
|
1428
1452
|
if (lifecycle.stopping)
|
|
1429
1453
|
return;
|
|
1430
1454
|
if (message.type === 'queue.ready')
|
|
1431
1455
|
void tryClaim(message.sessionId);
|
|
1432
1456
|
if (message.type === 'session.steer')
|
|
1433
|
-
|
|
1457
|
+
answer(handleSteer, message);
|
|
1434
1458
|
if (message.type === 'session.interrupt.request')
|
|
1435
|
-
|
|
1459
|
+
answer(handleInterrupt, message);
|
|
1436
1460
|
if (message.type === 'session.anchor.action.request')
|
|
1437
|
-
|
|
1461
|
+
answer(handleAnchorAction, message);
|
|
1438
1462
|
if (message.type === 'session.interaction.answer.request')
|
|
1439
|
-
|
|
1463
|
+
answer(handleInteractionAnswer, message);
|
|
1440
1464
|
if (message.type === 'session.file.browse.request')
|
|
1441
|
-
|
|
1465
|
+
answer(handleFileBrowse, message);
|
|
1442
1466
|
if (message.type === 'session.file.range.request')
|
|
1443
|
-
|
|
1467
|
+
answer(handleFileRange, message);
|
|
1444
1468
|
if (message.type === 'session.file.search.request')
|
|
1445
|
-
|
|
1469
|
+
answer(handleFileSearch, message);
|
|
1446
1470
|
if (message.type === 'session.git.read.request')
|
|
1447
|
-
|
|
1471
|
+
answer(handleGitRead, message);
|
|
1448
1472
|
if (message.type === 'session.harness.auth.request')
|
|
1449
|
-
|
|
1473
|
+
answer(handleHarnessAuth, message);
|
|
1450
1474
|
if (message.type === 'session.codex.update.request')
|
|
1451
|
-
|
|
1475
|
+
answer(handleHarnessUpdate, message);
|
|
1452
1476
|
if (message.type === 'session.file.edit.request')
|
|
1453
|
-
|
|
1477
|
+
answer(handleFileEdit, message);
|
|
1454
1478
|
});
|
|
1455
1479
|
const registered = new WeakSet();
|
|
1456
1480
|
const registering = new WeakSet();
|
|
@@ -1469,7 +1493,7 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1469
1493
|
git: true,
|
|
1470
1494
|
fileRanges: true,
|
|
1471
1495
|
harnessAuth: true,
|
|
1472
|
-
...(
|
|
1496
|
+
...updateCapabilities(harnessUpdate.available()),
|
|
1473
1497
|
},
|
|
1474
1498
|
workerId,
|
|
1475
1499
|
workerInstanceId,
|
|
@@ -1490,7 +1514,15 @@ export async function watchQueuedPrompts({ rpc, options, machine, getDescriptors
|
|
|
1490
1514
|
rpc.onConnect((control) => {
|
|
1491
1515
|
void registerControl(control);
|
|
1492
1516
|
});
|
|
1493
|
-
|
|
1517
|
+
// Starting offline must not end this function: the loop below is the only
|
|
1518
|
+
// thing that sends heartbeats, probes for a half-open socket and re-polls
|
|
1519
|
+
// for missed queue events, and it already tolerates a failed connection.
|
|
1520
|
+
try {
|
|
1521
|
+
await registerControl(await rpc.current());
|
|
1522
|
+
}
|
|
1523
|
+
catch (error) {
|
|
1524
|
+
logError(`Queue worker could not connect yet: ${error?.message || error}\n`);
|
|
1525
|
+
}
|
|
1494
1526
|
while (!lifecycle.stopping) {
|
|
1495
1527
|
await backgroundWait(MACHINE_HEARTBEAT_MS);
|
|
1496
1528
|
if (lifecycle.stopping)
|
package/dist/upload-sessions.js
CHANGED
|
@@ -404,6 +404,37 @@ export function linkNativeSessionParents(machine, descriptors) {
|
|
|
404
404
|
];
|
|
405
405
|
});
|
|
406
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* The row the server has reached, when a rejected ingest batch was in fact
|
|
409
|
+
* already stored in full; null when the rejection is a real one.
|
|
410
|
+
*/
|
|
411
|
+
export function ingestOffsetAcknowledges(error, startRow, rowCount) {
|
|
412
|
+
const offset = /^ROW_OFFSET:(\d+)$/u.exec(String(error?.code || ''));
|
|
413
|
+
if (!offset)
|
|
414
|
+
return null;
|
|
415
|
+
const reached = Number(offset[1]);
|
|
416
|
+
return reached >= startRow + rowCount ? reached : null;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Bring a descriptor up to the file as it is now.
|
|
420
|
+
*
|
|
421
|
+
* The inventory scan stats each transcript in a worker and the result is
|
|
422
|
+
* recorded here some time later. An agent that wrote in between leaves the
|
|
423
|
+
* descriptor describing a shorter file than the one on disk. Recording the
|
|
424
|
+
* fresh stat as "already seen" while uploading only to the descriptor's stale
|
|
425
|
+
* size strands those rows: nothing looks changed until the next append or the
|
|
426
|
+
* five-minute audit. A database projection's file is its whole store, not the
|
|
427
|
+
* session, so its size says nothing about the session and is left alone.
|
|
428
|
+
*/
|
|
429
|
+
export function adoptGrowthSinceInventory(descriptor, stat) {
|
|
430
|
+
if (String(descriptor.sourcePath || '').includes('.db#'))
|
|
431
|
+
return false;
|
|
432
|
+
if (Number(stat.size) === Number(descriptor.size))
|
|
433
|
+
return false;
|
|
434
|
+
descriptor.size = stat.size;
|
|
435
|
+
descriptor.mtimeMs = stat.mtimeMs;
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
407
438
|
export function nativeDescriptorFingerprint(descriptor) {
|
|
408
439
|
return JSON.stringify([
|
|
409
440
|
Number(descriptor.size) || 0,
|
|
@@ -638,6 +669,10 @@ export class RpcSocket {
|
|
|
638
669
|
this.socket.on('message', (data) => this.onMessage(String(data), (ready) => settle(resolve, ready), (error) => settle(reject, error)));
|
|
639
670
|
this.socket.on('close', (code, reason) => {
|
|
640
671
|
const error = socketError(`WebSocket closed (${code}): ${String(reason)}`);
|
|
672
|
+
// A server that accepts the upgrade and closes before saying "ready"
|
|
673
|
+
// (a deploy, a restarting object) has answered. Without this the
|
|
674
|
+
// connect waited out the whole ready deadline before it could retry.
|
|
675
|
+
settle(reject, error);
|
|
641
676
|
for (const request of this.pending.values()) {
|
|
642
677
|
clearTimeout(request.timer);
|
|
643
678
|
request.reject(error);
|
|
@@ -1087,13 +1122,27 @@ export async function uploadNativeSession(rpc, machine, descriptor, cursor = {},
|
|
|
1087
1122
|
if (!held.length)
|
|
1088
1123
|
return;
|
|
1089
1124
|
const rows = held.map((entry) => entry.event);
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1125
|
+
let acknowledgement;
|
|
1126
|
+
try {
|
|
1127
|
+
acknowledgement = await rpc.request({
|
|
1128
|
+
type: 'ingest',
|
|
1129
|
+
sessionId: uploadSessionId,
|
|
1130
|
+
startRow: rows[0].index,
|
|
1131
|
+
rows,
|
|
1132
|
+
...(initialUpload ? { deferReduction: true } : {}),
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
catch (error) {
|
|
1136
|
+
// The connection resends a request whose socket died. When only the
|
|
1137
|
+
// acknowledgement was lost, the server already holds these rows and
|
|
1138
|
+
// answers the resend with the offset it has reached. That is an
|
|
1139
|
+
// acknowledgement: treating it as a failure put the session into backoff
|
|
1140
|
+
// and then re-read its whole transcript from the first byte.
|
|
1141
|
+
const acknowledged = ingestOffsetAcknowledges(error, rows[0].index, rows.length);
|
|
1142
|
+
if (acknowledged === null)
|
|
1143
|
+
throw error;
|
|
1144
|
+
acknowledgement = { nextRow: acknowledged };
|
|
1145
|
+
}
|
|
1097
1146
|
nextRow = Number(acknowledgement.nextRow);
|
|
1098
1147
|
sent += rows.length;
|
|
1099
1148
|
batchesSent += 1;
|
|
@@ -1624,6 +1673,7 @@ async function uploadNative(options, machine, redactor = DEFAULT_REDACTOR) {
|
|
|
1624
1673
|
try {
|
|
1625
1674
|
const stat = fs.statSync(descriptor.file);
|
|
1626
1675
|
physicalStats.set(sessionId, { size: stat.size, mtimeMs: stat.mtimeMs });
|
|
1676
|
+
adoptGrowthSinceInventory(descriptor, stat);
|
|
1627
1677
|
}
|
|
1628
1678
|
catch { }
|
|
1629
1679
|
const saved = state.get(sessionId);
|
package/package.json
CHANGED
package/dist/codex-update.js
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
/** Machine-local Codex CLI self-update, callable by the Turns daemon. */
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import fs from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { executablePath } from "./executable-path.js";
|
|
7
|
-
import { codexCommandIsAppManaged, resolveHarnessCommand } from "./harness-registry.js";
|
|
8
|
-
const VERSION_TIMEOUT_MS = 15_000;
|
|
9
|
-
const UPDATE_TIMEOUT_MS = 5 * 60_000;
|
|
10
|
-
const OUTPUT_LIMIT = 64 * 1024;
|
|
11
|
-
/** Run the resolved executable directly—never through a shell—so update
|
|
12
|
-
* requests cannot become arbitrary command execution. */
|
|
13
|
-
function runCommand(command, args, timeoutMs) {
|
|
14
|
-
return new Promise((resolve) => {
|
|
15
|
-
const child = spawn(command, args, {
|
|
16
|
-
cwd: os.homedir(),
|
|
17
|
-
// npm and Codex's npm launcher are `#!/usr/bin/env node` scripts; a
|
|
18
|
-
// service manager's PATH may not include the daemon's own Node.
|
|
19
|
-
env: { ...process.env, PATH: executablePath() },
|
|
20
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
21
|
-
});
|
|
22
|
-
let output = '';
|
|
23
|
-
let settled = false;
|
|
24
|
-
const settle = (result) => {
|
|
25
|
-
if (settled)
|
|
26
|
-
return;
|
|
27
|
-
settled = true;
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
-
resolve(result);
|
|
30
|
-
};
|
|
31
|
-
const timer = setTimeout(() => {
|
|
32
|
-
try {
|
|
33
|
-
child.kill('SIGKILL');
|
|
34
|
-
}
|
|
35
|
-
catch { }
|
|
36
|
-
settle({ output, exitCode: null, timedOut: true });
|
|
37
|
-
}, timeoutMs);
|
|
38
|
-
timer.unref?.();
|
|
39
|
-
const collect = (chunk) => {
|
|
40
|
-
if (output.length < OUTPUT_LIMIT)
|
|
41
|
-
output += String(chunk).slice(0, OUTPUT_LIMIT - output.length);
|
|
42
|
-
};
|
|
43
|
-
child.stdout.on('data', collect);
|
|
44
|
-
child.stderr.on('data', collect);
|
|
45
|
-
child.on('error', () => settle({ output, exitCode: null, failed: true }));
|
|
46
|
-
child.on('close', (exitCode) => settle({ output, exitCode }));
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
export function parseCodexVersion(output) {
|
|
50
|
-
const text = String(output || '').trim();
|
|
51
|
-
const prefixed = text.match(/codex(?:-cli)?\s+([^\s]+)/iu);
|
|
52
|
-
return (prefixed?.[1] || text.split(/\s+/u).at(-1) || '').slice(0, 120);
|
|
53
|
-
}
|
|
54
|
-
function commandFailure(result, fallback) {
|
|
55
|
-
if (result.timedOut)
|
|
56
|
-
return new Error('The Codex update timed out.');
|
|
57
|
-
const lines = result.output
|
|
58
|
-
.replace(/\u001b\[[0-9;?]*[A-Za-z]/gu, '')
|
|
59
|
-
.split('\n')
|
|
60
|
-
.map((line) => line.trim())
|
|
61
|
-
.filter(Boolean);
|
|
62
|
-
return new Error(lines.at(-1) || fallback);
|
|
63
|
-
}
|
|
64
|
-
function realpathOrSelf(file) {
|
|
65
|
-
try {
|
|
66
|
-
return fs.realpathSync(file);
|
|
67
|
-
}
|
|
68
|
-
catch {
|
|
69
|
-
return file;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
function findExecutable(name, preferred) {
|
|
73
|
-
for (const directory of [...preferred, ...executablePath().split(path.delimiter)]) {
|
|
74
|
-
const candidate = path.join(directory, name);
|
|
75
|
-
try {
|
|
76
|
-
fs.accessSync(candidate, fs.constants.X_OK);
|
|
77
|
-
return candidate;
|
|
78
|
-
}
|
|
79
|
-
catch { }
|
|
80
|
-
}
|
|
81
|
-
return '';
|
|
82
|
-
}
|
|
83
|
-
export function codexUpgradeCommand(command, lookup = {}) {
|
|
84
|
-
const resolved = (lookup.realpath || realpathOrSelf)(command).replaceAll('\\', '/');
|
|
85
|
-
const find = lookup.findExecutable || findExecutable;
|
|
86
|
-
// `npm i -g @openai/codex` links <prefix>/bin/codex to the package's launcher.
|
|
87
|
-
const npm = resolved.match(/^(.*?)\/(?:lib\/)?node_modules\/@openai\/codex\//u);
|
|
88
|
-
// Windows npm is a .cmd shim, which cannot be spawned without a shell.
|
|
89
|
-
if (npm && process.platform !== 'win32') {
|
|
90
|
-
const prefix = npm[1];
|
|
91
|
-
const binary = find('npm', [path.join(prefix, 'bin')]);
|
|
92
|
-
if (binary)
|
|
93
|
-
return { manager: 'npm', command: binary, args: ['install', '-g', '--prefix', prefix, '@openai/codex@latest'] };
|
|
94
|
-
}
|
|
95
|
-
const brew = resolved.match(/^(.*?)\/(Cellar|Caskroom)\/codex\//u);
|
|
96
|
-
if (brew) {
|
|
97
|
-
const binary = find('brew', [path.join(brew[1], 'bin')]);
|
|
98
|
-
if (binary)
|
|
99
|
-
return {
|
|
100
|
-
manager: 'brew',
|
|
101
|
-
command: binary,
|
|
102
|
-
args: ['upgrade', ...(brew[2] === 'Caskroom' ? ['--cask'] : []), 'codex'],
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return { manager: 'codex', command, args: ['update'] };
|
|
106
|
-
}
|
|
107
|
-
export function createCodexUpdateManager(dependencies = {}) {
|
|
108
|
-
const resolveCommand = dependencies.resolveCommand || (() => resolveHarnessCommand('codex'));
|
|
109
|
-
const isAppManaged = dependencies.isAppManaged || codexCommandIsAppManaged;
|
|
110
|
-
const run = dependencies.run || runCommand;
|
|
111
|
-
const upgradeCommand = dependencies.upgradeCommand || ((command) => codexUpgradeCommand(command));
|
|
112
|
-
let active = null;
|
|
113
|
-
const updateCommand = () => {
|
|
114
|
-
const command = resolveCommand();
|
|
115
|
-
if (isAppManaged(command)) {
|
|
116
|
-
throw new Error('Codex is managed by the macOS app. Update the Codex app to update its bundled CLI.');
|
|
117
|
-
}
|
|
118
|
-
return command;
|
|
119
|
-
};
|
|
120
|
-
const perform = async () => {
|
|
121
|
-
const beforeRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
122
|
-
if (beforeRun.failed || beforeRun.timedOut || beforeRun.exitCode !== 0) {
|
|
123
|
-
throw commandFailure(beforeRun, 'Codex is not installed or its version could not be read.');
|
|
124
|
-
}
|
|
125
|
-
const previousVersion = parseCodexVersion(beforeRun.output);
|
|
126
|
-
const upgrade = upgradeCommand(updateCommand());
|
|
127
|
-
const updateRun = await run(upgrade.command, upgrade.args, UPDATE_TIMEOUT_MS);
|
|
128
|
-
if (updateRun.failed || updateRun.timedOut || updateRun.exitCode !== 0) {
|
|
129
|
-
throw commandFailure(updateRun, upgrade.manager === 'codex'
|
|
130
|
-
? 'Codex could not update itself. Run `codex update` in a terminal on the machine.'
|
|
131
|
-
: `${upgrade.manager} could not upgrade Codex. Run \`${[path.basename(upgrade.command), ...upgrade.args].join(' ')}\` in a terminal on the machine.`);
|
|
132
|
-
}
|
|
133
|
-
// Resolve again because the updater may replace a shim or install path.
|
|
134
|
-
const afterRun = await run(updateCommand(), ['--version'], VERSION_TIMEOUT_MS);
|
|
135
|
-
if (afterRun.failed || afterRun.timedOut || afterRun.exitCode !== 0) {
|
|
136
|
-
throw commandFailure(afterRun, 'Codex updated, but its new version could not be read.');
|
|
137
|
-
}
|
|
138
|
-
const currentVersion = parseCodexVersion(afterRun.output);
|
|
139
|
-
return {
|
|
140
|
-
...(previousVersion ? { previousVersion } : {}),
|
|
141
|
-
...(currentVersion ? { currentVersion } : {}),
|
|
142
|
-
updated: Boolean(previousVersion && currentVersion && previousVersion !== currentVersion),
|
|
143
|
-
};
|
|
144
|
-
};
|
|
145
|
-
return {
|
|
146
|
-
/** App-bundled Codex is updated with the app, not by its CLI command. */
|
|
147
|
-
available() {
|
|
148
|
-
try {
|
|
149
|
-
return !isAppManaged(resolveCommand());
|
|
150
|
-
}
|
|
151
|
-
catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
/** Concurrent clicks share one installer process instead of racing writes. */
|
|
156
|
-
update() {
|
|
157
|
-
if (!active)
|
|
158
|
-
active = perform().finally(() => (active = null));
|
|
159
|
-
return active;
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
}
|