amalgm 0.1.78 → 0.1.79
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 +4 -2
- package/lib/cli.js +47 -102
- package/lib/paths.js +2 -0
- package/lib/service.js +8 -0
- package/lib/supervisor.js +322 -0
- package/lib/tunnel-chat.js +19 -0
- package/lib/tunnel-events.js +6 -0
- package/lib/updater.js +205 -0
- package/package.json +2 -2
- package/runtime/scripts/chat-core/adapters/claude.js +1 -0
- package/runtime/scripts/chat-core/engine.js +8 -0
- package/runtime/scripts/chat-core/normalizers/claude.js +44 -7
- package/runtime/scripts/chat-core/server.js +2 -0
- package/runtime/scripts/chat-core/stores.js +9 -0
- package/runtime/scripts/chat-core/tests/claude-normalizer.test.js +88 -0
- package/runtime/scripts/chat-core/tests/server.test.js +40 -0
package/README.md
CHANGED
|
@@ -9,8 +9,8 @@ amalgm doctor
|
|
|
9
9
|
amalgm start
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
To move an existing install to the newest stable published build, run `amalgm update`.
|
|
13
|
-
|
|
12
|
+
To move an existing install to the newest stable published build immediately, run `amalgm update`.
|
|
13
|
+
`amalgm start` checks for updates before launch by default; set `AMALGM_AUTO_UPDATE=0` or `AMALGM_DISABLE_AUTO_UPDATE=1` to opt out.
|
|
14
14
|
|
|
15
15
|
`amalgm login` opens a browser approval page, then registers the machine and stores its local tunnel/computer record in `~/.amalgm/computer.json`.
|
|
16
16
|
|
|
@@ -34,6 +34,8 @@ amalgm run
|
|
|
34
34
|
- Linux host with real user systemd: systemd user service
|
|
35
35
|
- random/container shell where no service manager is available: portable best-effort watchdog
|
|
36
36
|
|
|
37
|
+
Running service-managed runtimes also check for updates in the background. Normal updates restart only when chat/tunnel work is idle; critical updates can restart immediately. If the runtime is not service-managed, background updates are installed but the running process stays on the old code until it is manually restarted.
|
|
38
|
+
|
|
37
39
|
The runtime supervisor keeps the declared local essentials alive:
|
|
38
40
|
|
|
39
41
|
- port monitor on `8081`
|
package/lib/cli.js
CHANGED
|
@@ -28,7 +28,6 @@ const {
|
|
|
28
28
|
} = require('./paths');
|
|
29
29
|
const {
|
|
30
30
|
normalizeRuntimeLabel,
|
|
31
|
-
npmTagForRuntimeLabel,
|
|
32
31
|
sanitizeScopeSegment,
|
|
33
32
|
} = require('./runtime-identity');
|
|
34
33
|
const { startSupervisor } = require('./supervisor');
|
|
@@ -65,6 +64,10 @@ const {
|
|
|
65
64
|
ensureAgentCommandShims,
|
|
66
65
|
ensureNativeBinaries,
|
|
67
66
|
} = require('../runtime/scripts/chat-core/tooling/native-binaries');
|
|
67
|
+
const {
|
|
68
|
+
shouldAutoUpdate,
|
|
69
|
+
updatePackage,
|
|
70
|
+
} = require('./updater');
|
|
68
71
|
|
|
69
72
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
70
73
|
const SETUP_CODE_LENGTH = 16;
|
|
@@ -107,7 +110,7 @@ function usage() {
|
|
|
107
110
|
'',
|
|
108
111
|
'Usage:',
|
|
109
112
|
' amalgm login [--app-url https://amalgm.ai] [--name "My Mac"] [--setup-code ABCD-EFGH-JKLM-NPQR] [--no-open] [--no-poll]',
|
|
110
|
-
' amalgm start [--foreground] [--local-only] [--branch main|preview] [--user <user-id>]',
|
|
113
|
+
' amalgm start [--foreground] [--local-only] [--no-auto-update] [--branch main|preview] [--user <user-id>]',
|
|
111
114
|
' amalgm run [--local-only]',
|
|
112
115
|
' amalgm stop',
|
|
113
116
|
' amalgm status',
|
|
@@ -119,7 +122,8 @@ function usage() {
|
|
|
119
122
|
'',
|
|
120
123
|
'Environment:',
|
|
121
124
|
' AMALGM_APP_URL Web app base URL for login/register',
|
|
122
|
-
' AMALGM_AUTO_UPDATE
|
|
125
|
+
' AMALGM_AUTO_UPDATE Auto-update before start unless set to 0/false/off',
|
|
126
|
+
' AMALGM_DISABLE_AUTO_UPDATE Set to 1 to disable startup/background auto-updates',
|
|
123
127
|
' AMALGM_DIR Explicit runtime state dir override',
|
|
124
128
|
' AMALGM_HOME Runtime state root (default ~/.amalgm)',
|
|
125
129
|
' AMALGM_RUNTIME_LABEL Runtime label: main, preview, or local',
|
|
@@ -197,13 +201,21 @@ function hasRequiredComputerFields(record) {
|
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
const SAFE_DAEMON_ENV_KEYS = [
|
|
204
|
+
'AMALGM_AUTO_UPDATE',
|
|
200
205
|
'AMALGM_BIND_HOST',
|
|
201
206
|
'AMALGM_BRANCH',
|
|
202
207
|
'AMALGM_DEFAULT_CWD',
|
|
208
|
+
'AMALGM_DISABLE_AUTO_UPDATE',
|
|
203
209
|
'AMALGM_GATEWAY_PORT',
|
|
204
210
|
'AMALGM_HOME',
|
|
205
211
|
'AMALGM_MCP_PORT',
|
|
206
212
|
'AMALGM_NATIVE_NODE_MODULES',
|
|
213
|
+
'AMALGM_UPDATE_CHECK_INTERVAL_MS',
|
|
214
|
+
'AMALGM_UPDATE_CRITICAL',
|
|
215
|
+
'AMALGM_UPDATE_IDLE_TIMEOUT_MS',
|
|
216
|
+
'AMALGM_UPDATE_INITIAL_DELAY_MS',
|
|
217
|
+
'AMALGM_UPDATE_IDLE_POLL_MS',
|
|
218
|
+
'AMALGM_AUTO_UPDATE_CRITICAL',
|
|
207
219
|
'AMALGM_RUNTIME_INSTANCE_ID',
|
|
208
220
|
'AMALGM_RUNTIME_LABEL',
|
|
209
221
|
'AMALGM_RUNTIME_STATE_DIR',
|
|
@@ -421,91 +433,6 @@ function commandExists(command) {
|
|
|
421
433
|
});
|
|
422
434
|
}
|
|
423
435
|
|
|
424
|
-
function parseSemver(value) {
|
|
425
|
-
const [core, prerelease = ''] = String(value || '').trim().replace(/^v/, '').split('-', 2);
|
|
426
|
-
const parts = core.split('.').map((part) => Number.parseInt(part, 10));
|
|
427
|
-
return {
|
|
428
|
-
major: Number.isFinite(parts[0]) ? parts[0] : 0,
|
|
429
|
-
minor: Number.isFinite(parts[1]) ? parts[1] : 0,
|
|
430
|
-
patch: Number.isFinite(parts[2]) ? parts[2] : 0,
|
|
431
|
-
prerelease: prerelease ? prerelease.split('.') : [],
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
function comparePrerelease(left, right) {
|
|
436
|
-
if (left.length === 0 && right.length === 0) return 0;
|
|
437
|
-
if (left.length === 0) return 1;
|
|
438
|
-
if (right.length === 0) return -1;
|
|
439
|
-
|
|
440
|
-
const length = Math.max(left.length, right.length);
|
|
441
|
-
for (let i = 0; i < length; i += 1) {
|
|
442
|
-
const a = left[i];
|
|
443
|
-
const b = right[i];
|
|
444
|
-
if (a === undefined) return -1;
|
|
445
|
-
if (b === undefined) return 1;
|
|
446
|
-
const aNum = /^\d+$/.test(a) ? Number.parseInt(a, 10) : null;
|
|
447
|
-
const bNum = /^\d+$/.test(b) ? Number.parseInt(b, 10) : null;
|
|
448
|
-
if (aNum !== null && bNum !== null && aNum !== bNum) return aNum > bNum ? 1 : -1;
|
|
449
|
-
if (aNum !== null && bNum === null) return -1;
|
|
450
|
-
if (aNum === null && bNum !== null) return 1;
|
|
451
|
-
if (a !== b) return a > b ? 1 : -1;
|
|
452
|
-
}
|
|
453
|
-
return 0;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function compareVersions(left, right) {
|
|
457
|
-
const a = parseSemver(left);
|
|
458
|
-
const b = parseSemver(right);
|
|
459
|
-
for (const key of ['major', 'minor', 'patch']) {
|
|
460
|
-
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
|
|
461
|
-
}
|
|
462
|
-
return comparePrerelease(a.prerelease, b.prerelease);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
function npmCommand() {
|
|
466
|
-
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function npmInstallTag(options = {}) {
|
|
470
|
-
const explicit = options.tag || options.channel;
|
|
471
|
-
if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
|
|
472
|
-
return npmTagForRuntimeLabel(AMALGM_RUNTIME_LABEL);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function npmPackageVersionForTag(tag) {
|
|
476
|
-
const result = spawnSync(npmCommand(), ['view', `amalgm@${tag}`, 'version', '--json'], {
|
|
477
|
-
encoding: 'utf8',
|
|
478
|
-
timeout: 15000,
|
|
479
|
-
env: {
|
|
480
|
-
...process.env,
|
|
481
|
-
npm_config_audit: 'false',
|
|
482
|
-
npm_config_fund: 'false',
|
|
483
|
-
},
|
|
484
|
-
});
|
|
485
|
-
if (result.status !== 0) {
|
|
486
|
-
const detail = String(result.stderr || result.stdout || '').trim();
|
|
487
|
-
throw new Error(`Could not check npm dist-tag "${tag}". ${detail}`);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const raw = String(result.stdout || '').trim();
|
|
491
|
-
try {
|
|
492
|
-
return JSON.parse(raw);
|
|
493
|
-
} catch {
|
|
494
|
-
return raw.replace(/^"|"$/g, '');
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function installNpmPackageTag(tag) {
|
|
499
|
-
return spawnSync(npmCommand(), ['install', '-g', `amalgm@${tag}`], {
|
|
500
|
-
stdio: 'inherit',
|
|
501
|
-
env: {
|
|
502
|
-
...process.env,
|
|
503
|
-
npm_config_audit: 'false',
|
|
504
|
-
npm_config_fund: 'false',
|
|
505
|
-
},
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
|
|
509
436
|
function openBrowser(url) {
|
|
510
437
|
const command =
|
|
511
438
|
process.platform === 'darwin'
|
|
@@ -819,22 +746,21 @@ function ensureAgentRuntimeCommands(options = {}) {
|
|
|
819
746
|
}
|
|
820
747
|
|
|
821
748
|
async function update(options = {}) {
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
749
|
+
const result = await updatePackage({
|
|
750
|
+
...options,
|
|
751
|
+
currentVersion: PACKAGE_VERSION,
|
|
752
|
+
onOutput: (text, stream) => {
|
|
753
|
+
if (stream === 'stderr') process.stderr.write(text);
|
|
754
|
+
else process.stdout.write(text);
|
|
755
|
+
},
|
|
756
|
+
});
|
|
825
757
|
|
|
826
|
-
if (
|
|
827
|
-
console.log(`Amalgm is already current for ${tag}: ${PACKAGE_VERSION}`);
|
|
758
|
+
if (!result.updated) {
|
|
759
|
+
console.log(`Amalgm is already current for ${result.tag}: ${PACKAGE_VERSION}`);
|
|
828
760
|
return false;
|
|
829
761
|
}
|
|
830
762
|
|
|
831
|
-
console.log(`
|
|
832
|
-
const result = installNpmPackageTag(tag);
|
|
833
|
-
if (result.status !== 0) {
|
|
834
|
-
throw new Error(`npm install -g amalgm@${tag} failed with status ${result.status ?? result.signal ?? 'unknown'}`);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
console.log(`Amalgm updated to ${targetVersion}.`);
|
|
763
|
+
console.log(`Amalgm updated from ${PACKAGE_VERSION} to ${result.targetVersion} (${result.tag}).`);
|
|
838
764
|
const pid = readPid();
|
|
839
765
|
if (isPidRunning(pid)) {
|
|
840
766
|
console.log('A previous Amalgm daemon is still running. Run `amalgm stop && amalgm start` to restart it with the new package.');
|
|
@@ -843,7 +769,9 @@ async function update(options = {}) {
|
|
|
843
769
|
}
|
|
844
770
|
|
|
845
771
|
async function updateBeforeStartIfRequested(options = {}) {
|
|
846
|
-
const requested = options['auto-update']
|
|
772
|
+
const requested = !options['no-auto-update']
|
|
773
|
+
&& process.env.AMALGM_AUTO_UPDATE_RELAUNCHED !== '1'
|
|
774
|
+
&& shouldAutoUpdate();
|
|
847
775
|
if (!requested) return false;
|
|
848
776
|
|
|
849
777
|
try {
|
|
@@ -854,11 +782,28 @@ async function updateBeforeStartIfRequested(options = {}) {
|
|
|
854
782
|
}
|
|
855
783
|
}
|
|
856
784
|
|
|
785
|
+
function relaunchCurrentCommandAfterUpdate() {
|
|
786
|
+
console.log('Launching the updated Amalgm package.');
|
|
787
|
+
const result = spawnSync(process.execPath, process.argv.slice(1), {
|
|
788
|
+
stdio: 'inherit',
|
|
789
|
+
env: {
|
|
790
|
+
...process.env,
|
|
791
|
+
AMALGM_AUTO_UPDATE_RELAUNCHED: '1',
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
if (result.error) throw result.error;
|
|
795
|
+
if (result.signal) {
|
|
796
|
+
process.kill(process.pid, result.signal);
|
|
797
|
+
return 1;
|
|
798
|
+
}
|
|
799
|
+
return Number.isInteger(result.status) ? result.status : 0;
|
|
800
|
+
}
|
|
801
|
+
|
|
857
802
|
async function start(options) {
|
|
858
803
|
ensureBaseDirs();
|
|
859
804
|
|
|
860
805
|
if (await updateBeforeStartIfRequested(options)) {
|
|
861
|
-
|
|
806
|
+
process.exitCode = relaunchCurrentCommandAfterUpdate();
|
|
862
807
|
return;
|
|
863
808
|
}
|
|
864
809
|
|
package/lib/paths.js
CHANGED
|
@@ -40,6 +40,7 @@ const AUTH_FILE = path.join(AMALGM_DIR, 'auth.json');
|
|
|
40
40
|
const RUNTIME_TOKEN_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'runtime-token.json');
|
|
41
41
|
const PID_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'amalgm.pid');
|
|
42
42
|
const RUNTIME_STATE_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'runtime-state.json');
|
|
43
|
+
const AUTO_UPDATE_STATE_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'auto-update-state.json');
|
|
43
44
|
const LOG_DIR = path.join(AMALGM_RUNTIME_STATE_DIR, 'logs');
|
|
44
45
|
const SERVICE_DIR = path.join(AMALGM_RUNTIME_STATE_DIR, 'service');
|
|
45
46
|
const SERVICE_PID_FILE = path.join(AMALGM_RUNTIME_STATE_DIR, 'amalgm-service.pid');
|
|
@@ -57,6 +58,7 @@ module.exports = {
|
|
|
57
58
|
AMALGM_RUNTIME_LABEL,
|
|
58
59
|
AMALGM_RUNTIME_USER_SCOPE,
|
|
59
60
|
AUTH_FILE,
|
|
61
|
+
AUTO_UPDATE_STATE_FILE,
|
|
60
62
|
COMPUTER_FILE,
|
|
61
63
|
DEFAULT_APP_URL,
|
|
62
64
|
DEVICE_FILE,
|
package/lib/service.js
CHANGED
|
@@ -38,9 +38,11 @@ const AMALGM_BIN = path.join(PACKAGE_ROOT, 'bin', 'amalgm.js');
|
|
|
38
38
|
const SERVICE_ENV_KEYS = [
|
|
39
39
|
'AMALGM_APP_URL',
|
|
40
40
|
'AMALGM_AUTH_BACKUP_ENABLED',
|
|
41
|
+
'AMALGM_AUTO_UPDATE',
|
|
41
42
|
'AMALGM_BIND_HOST',
|
|
42
43
|
'AMALGM_CREATE_AUTH_WATCH_DIRS',
|
|
43
44
|
'AMALGM_DEFAULT_CWD',
|
|
45
|
+
'AMALGM_DISABLE_AUTO_UPDATE',
|
|
44
46
|
'AMALGM_GATEWAY_PORT',
|
|
45
47
|
'AMALGM_HOME',
|
|
46
48
|
'AMALGM_RUNTIME_INSTANCE_ID',
|
|
@@ -50,6 +52,12 @@ const SERVICE_ENV_KEYS = [
|
|
|
50
52
|
'AMALGM_NATIVE_NODE_MODULES',
|
|
51
53
|
'AMALGM_PROJECTS_DIR',
|
|
52
54
|
'AMALGM_SKIP_NATIVE_INSTALL',
|
|
55
|
+
'AMALGM_UPDATE_CHECK_INTERVAL_MS',
|
|
56
|
+
'AMALGM_UPDATE_CRITICAL',
|
|
57
|
+
'AMALGM_UPDATE_IDLE_TIMEOUT_MS',
|
|
58
|
+
'AMALGM_UPDATE_INITIAL_DELAY_MS',
|
|
59
|
+
'AMALGM_UPDATE_IDLE_POLL_MS',
|
|
60
|
+
'AMALGM_AUTO_UPDATE_CRITICAL',
|
|
53
61
|
'AMALGM_WORKSPACES_DIR',
|
|
54
62
|
'CHAT_SERVER_PORT',
|
|
55
63
|
'ELECTRON_RUN_AS_NODE',
|
package/lib/supervisor.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
AMALGM_DIR,
|
|
13
13
|
AMALGM_BRANCH,
|
|
14
14
|
AMALGM_HOME,
|
|
15
|
+
AUTO_UPDATE_STATE_FILE,
|
|
15
16
|
LOG_DIR,
|
|
16
17
|
PID_FILE,
|
|
17
18
|
AMALGM_RUNTIME_INSTANCE_ID,
|
|
@@ -44,6 +45,11 @@ const {
|
|
|
44
45
|
runtimePortsState,
|
|
45
46
|
runtimeServiceScripts,
|
|
46
47
|
} = require('./runtime-manifest');
|
|
48
|
+
const {
|
|
49
|
+
checkForUpdate,
|
|
50
|
+
shouldAutoUpdate,
|
|
51
|
+
updatePackage,
|
|
52
|
+
} = require('./updater');
|
|
47
53
|
const PACKAGE_VERSION = require('../package.json').version;
|
|
48
54
|
|
|
49
55
|
const CHILD_RESTART_POLICY = {
|
|
@@ -61,6 +67,13 @@ const CHILD_HEALTH_POLICY = {
|
|
|
61
67
|
maxFailures: 3,
|
|
62
68
|
};
|
|
63
69
|
|
|
70
|
+
const AUTO_UPDATE_POLICY = {
|
|
71
|
+
checkIntervalMs: 15 * 60_000,
|
|
72
|
+
initialDelayMs: 60_000,
|
|
73
|
+
idlePollMs: 10_000,
|
|
74
|
+
idleTimeoutMs: 2_500,
|
|
75
|
+
};
|
|
76
|
+
|
|
64
77
|
function ensureDir(dir, mode = 0o700) {
|
|
65
78
|
fs.mkdirSync(dir, { recursive: true, mode });
|
|
66
79
|
try {
|
|
@@ -374,6 +387,304 @@ function appendServiceState(name, update) {
|
|
|
374
387
|
updateRuntimeState({ services: next });
|
|
375
388
|
}
|
|
376
389
|
|
|
390
|
+
function numberFromEnv(name, fallback, minimum = 0) {
|
|
391
|
+
const value = Number(process.env[name]);
|
|
392
|
+
if (!Number.isFinite(value) || value <= 0) return fallback;
|
|
393
|
+
return Math.max(minimum, value);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function sleep(ms) {
|
|
397
|
+
return new Promise((resolve) => {
|
|
398
|
+
const timer = setTimeout(resolve, ms);
|
|
399
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function writeAutoUpdateState(update) {
|
|
404
|
+
const current = readJson(AUTO_UPDATE_STATE_FILE, {});
|
|
405
|
+
const next = {
|
|
406
|
+
...current,
|
|
407
|
+
...update,
|
|
408
|
+
updated_at: new Date().toISOString(),
|
|
409
|
+
};
|
|
410
|
+
writeJsonSecret(AUTO_UPDATE_STATE_FILE, next);
|
|
411
|
+
updateRuntimeState({
|
|
412
|
+
auto_update: {
|
|
413
|
+
status: next.status,
|
|
414
|
+
current_version: next.current_version || PACKAGE_VERSION,
|
|
415
|
+
target_version: next.target_version || null,
|
|
416
|
+
tag: next.tag || null,
|
|
417
|
+
critical: !!next.critical,
|
|
418
|
+
service_managed: !!next.service_managed,
|
|
419
|
+
last_checked_at: next.last_checked_at || null,
|
|
420
|
+
last_error: next.last_error || null,
|
|
421
|
+
active_turns: next.active_turns ?? null,
|
|
422
|
+
mcp_active: next.mcp_active ?? null,
|
|
423
|
+
tunnel_active: next.tunnel_active ?? null,
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
return next;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function localJsonRequest(port, requestPath, runtimeToken, timeoutMs) {
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
const req = http.request(
|
|
432
|
+
{
|
|
433
|
+
host: '127.0.0.1',
|
|
434
|
+
port,
|
|
435
|
+
path: requestPath,
|
|
436
|
+
method: 'GET',
|
|
437
|
+
timeout: timeoutMs,
|
|
438
|
+
headers: runtimeToken ? { 'x-amalgm-runtime-token': runtimeToken } : {},
|
|
439
|
+
},
|
|
440
|
+
(res) => {
|
|
441
|
+
const chunks = [];
|
|
442
|
+
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
|
443
|
+
res.on('end', () => {
|
|
444
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
445
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
446
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body}`));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
resolve(body ? JSON.parse(body) : {});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
reject(error);
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
},
|
|
456
|
+
);
|
|
457
|
+
req.on('timeout', () => {
|
|
458
|
+
req.destroy(new Error('request timed out'));
|
|
459
|
+
});
|
|
460
|
+
req.on('error', reject);
|
|
461
|
+
req.end();
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function runtimeIdleStatus({ ports, runtimeToken, tunnels }) {
|
|
466
|
+
const tunnelActive = tunnels.reduce((sum, tunnel) => (
|
|
467
|
+
sum + (typeof tunnel.activeCount === 'function' ? Number(tunnel.activeCount()) || 0 : 0)
|
|
468
|
+
), 0);
|
|
469
|
+
const timeoutMs = numberFromEnv('AMALGM_UPDATE_IDLE_TIMEOUT_MS', AUTO_UPDATE_POLICY.idleTimeoutMs, 500);
|
|
470
|
+
const [chat, mcp] = await Promise.all([
|
|
471
|
+
localJsonRequest(ports.chatServer, '/runtime-idle', runtimeToken, timeoutMs),
|
|
472
|
+
localJsonRequest(ports.amalgmMcp, '/healthz', runtimeToken, timeoutMs),
|
|
473
|
+
]);
|
|
474
|
+
const activeTurns = Number(chat?.active_turns) || 0;
|
|
475
|
+
const mcpActive = Number(mcp?.activeAgentConversations) || 0;
|
|
476
|
+
return {
|
|
477
|
+
idle: activeTurns === 0 && mcpActive === 0 && tunnelActive === 0,
|
|
478
|
+
active_turns: activeTurns,
|
|
479
|
+
mcp_active: mcpActive,
|
|
480
|
+
tunnel_active: tunnelActive,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function envFlag(name) {
|
|
485
|
+
const value = String(process.env[name] || '').trim().toLowerCase();
|
|
486
|
+
return ['1', 'true', 'yes', 'on'].includes(value);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isServiceManaged() {
|
|
490
|
+
return envFlag('AMALGM_SERVICE_MANAGED');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function isCriticalUpdate(check) {
|
|
494
|
+
if (check?.critical === true || check?.required === true) return true;
|
|
495
|
+
return envFlag('AMALGM_UPDATE_CRITICAL') || envFlag('AMALGM_AUTO_UPDATE_CRITICAL');
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function createAutoUpdateManager({ ports, runtimeToken, tunnels, foreground, requestRestart }) {
|
|
499
|
+
let stopped = false;
|
|
500
|
+
let timer = null;
|
|
501
|
+
let inFlight = false;
|
|
502
|
+
let installedUpdate = false;
|
|
503
|
+
const checkIntervalMs = numberFromEnv(
|
|
504
|
+
'AMALGM_UPDATE_CHECK_INTERVAL_MS',
|
|
505
|
+
AUTO_UPDATE_POLICY.checkIntervalMs,
|
|
506
|
+
60_000,
|
|
507
|
+
);
|
|
508
|
+
const initialDelayMs = numberFromEnv(
|
|
509
|
+
'AMALGM_UPDATE_INITIAL_DELAY_MS',
|
|
510
|
+
AUTO_UPDATE_POLICY.initialDelayMs,
|
|
511
|
+
5_000,
|
|
512
|
+
);
|
|
513
|
+
const idlePollMs = numberFromEnv(
|
|
514
|
+
'AMALGM_UPDATE_IDLE_POLL_MS',
|
|
515
|
+
AUTO_UPDATE_POLICY.idlePollMs,
|
|
516
|
+
1_000,
|
|
517
|
+
);
|
|
518
|
+
const serviceManaged = isServiceManaged();
|
|
519
|
+
|
|
520
|
+
function log(message, stream = 'stdout') {
|
|
521
|
+
const line = `[auto-update] ${message}`;
|
|
522
|
+
if (foreground) {
|
|
523
|
+
if (stream === 'stderr') console.error(line);
|
|
524
|
+
else console.log(line);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function schedule(delayMs) {
|
|
529
|
+
if (stopped || installedUpdate || timer) return;
|
|
530
|
+
timer = setTimeout(() => {
|
|
531
|
+
timer = null;
|
|
532
|
+
void checkAndUpdate();
|
|
533
|
+
}, delayMs);
|
|
534
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function waitForIdleAndRestart(check) {
|
|
538
|
+
const critical = isCriticalUpdate(check);
|
|
539
|
+
if (!serviceManaged) {
|
|
540
|
+
writeAutoUpdateState({
|
|
541
|
+
status: critical ? 'critical_restart_required' : 'restart_required',
|
|
542
|
+
current_version: PACKAGE_VERSION,
|
|
543
|
+
target_version: check.targetVersion,
|
|
544
|
+
tag: check.tag,
|
|
545
|
+
critical,
|
|
546
|
+
service_managed: false,
|
|
547
|
+
idle_blocked_reason: 'runtime is not service-managed',
|
|
548
|
+
});
|
|
549
|
+
log(`updated to ${check.targetVersion}; restart required because this runtime is not service-managed`);
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (critical) {
|
|
554
|
+
writeAutoUpdateState({
|
|
555
|
+
status: 'critical_restart_pending',
|
|
556
|
+
current_version: PACKAGE_VERSION,
|
|
557
|
+
target_version: check.targetVersion,
|
|
558
|
+
tag: check.tag,
|
|
559
|
+
critical: true,
|
|
560
|
+
service_managed: true,
|
|
561
|
+
idle_blocked_reason: null,
|
|
562
|
+
});
|
|
563
|
+
log(`critical update installed; restarting into ${check.targetVersion}`);
|
|
564
|
+
requestRestart('critical_auto_update');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
while (!stopped) {
|
|
569
|
+
let idle;
|
|
570
|
+
try {
|
|
571
|
+
idle = await runtimeIdleStatus({ ports, runtimeToken, tunnels });
|
|
572
|
+
} catch (error) {
|
|
573
|
+
idle = {
|
|
574
|
+
idle: false,
|
|
575
|
+
active_turns: null,
|
|
576
|
+
mcp_active: null,
|
|
577
|
+
tunnel_active: null,
|
|
578
|
+
reason: error.message,
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
writeAutoUpdateState({
|
|
583
|
+
status: 'restart_pending',
|
|
584
|
+
current_version: PACKAGE_VERSION,
|
|
585
|
+
target_version: check.targetVersion,
|
|
586
|
+
tag: check.tag,
|
|
587
|
+
critical: false,
|
|
588
|
+
service_managed: true,
|
|
589
|
+
active_turns: idle.active_turns,
|
|
590
|
+
mcp_active: idle.mcp_active,
|
|
591
|
+
tunnel_active: idle.tunnel_active,
|
|
592
|
+
idle_blocked_reason: idle.idle ? null : idle.reason || 'runtime is busy',
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
if (idle.idle) {
|
|
596
|
+
log(`restarting into ${check.targetVersion}`);
|
|
597
|
+
requestRestart('auto_update');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
await sleep(idlePollMs);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function checkAndUpdate() {
|
|
605
|
+
if (stopped || inFlight || installedUpdate) return;
|
|
606
|
+
inFlight = true;
|
|
607
|
+
const checkedAt = new Date().toISOString();
|
|
608
|
+
try {
|
|
609
|
+
writeAutoUpdateState({
|
|
610
|
+
status: 'checking',
|
|
611
|
+
current_version: PACKAGE_VERSION,
|
|
612
|
+
target_version: null,
|
|
613
|
+
tag: null,
|
|
614
|
+
service_managed: serviceManaged,
|
|
615
|
+
last_checked_at: checkedAt,
|
|
616
|
+
last_error: null,
|
|
617
|
+
});
|
|
618
|
+
const check = await checkForUpdate({ currentVersion: PACKAGE_VERSION });
|
|
619
|
+
if (!check.updateAvailable) {
|
|
620
|
+
writeAutoUpdateState({
|
|
621
|
+
status: 'current',
|
|
622
|
+
current_version: PACKAGE_VERSION,
|
|
623
|
+
target_version: check.targetVersion,
|
|
624
|
+
tag: check.tag,
|
|
625
|
+
service_managed: serviceManaged,
|
|
626
|
+
last_checked_at: checkedAt,
|
|
627
|
+
last_error: null,
|
|
628
|
+
});
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
log(`installing ${check.targetVersion} from ${check.tag}`);
|
|
633
|
+
writeAutoUpdateState({
|
|
634
|
+
status: 'installing',
|
|
635
|
+
current_version: PACKAGE_VERSION,
|
|
636
|
+
target_version: check.targetVersion,
|
|
637
|
+
tag: check.tag,
|
|
638
|
+
critical: isCriticalUpdate(check),
|
|
639
|
+
service_managed: serviceManaged,
|
|
640
|
+
last_checked_at: checkedAt,
|
|
641
|
+
last_error: null,
|
|
642
|
+
});
|
|
643
|
+
await updatePackage({
|
|
644
|
+
check,
|
|
645
|
+
onOutput: (text, stream) => {
|
|
646
|
+
const trimmed = String(text || '').trim();
|
|
647
|
+
if (trimmed) log(trimmed, stream);
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
installedUpdate = true;
|
|
651
|
+
await waitForIdleAndRestart(check);
|
|
652
|
+
} catch (error) {
|
|
653
|
+
writeAutoUpdateState({
|
|
654
|
+
status: 'error',
|
|
655
|
+
current_version: PACKAGE_VERSION,
|
|
656
|
+
service_managed: serviceManaged,
|
|
657
|
+
last_checked_at: checkedAt,
|
|
658
|
+
last_error: error.message,
|
|
659
|
+
});
|
|
660
|
+
log(`skipped: ${error.message}`, 'stderr');
|
|
661
|
+
} finally {
|
|
662
|
+
inFlight = false;
|
|
663
|
+
if (!stopped && !installedUpdate) schedule(checkIntervalMs);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
start() {
|
|
669
|
+
if (!shouldAutoUpdate()) {
|
|
670
|
+
writeAutoUpdateState({
|
|
671
|
+
status: 'disabled',
|
|
672
|
+
current_version: PACKAGE_VERSION,
|
|
673
|
+
service_managed: serviceManaged,
|
|
674
|
+
last_error: null,
|
|
675
|
+
});
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
schedule(initialDelayMs);
|
|
679
|
+
},
|
|
680
|
+
stop() {
|
|
681
|
+
stopped = true;
|
|
682
|
+
if (timer) clearTimeout(timer);
|
|
683
|
+
timer = null;
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
377
688
|
function checkServiceHealth(spec, timeoutMs = CHILD_HEALTH_POLICY.timeoutMs) {
|
|
378
689
|
if (!spec.port) return Promise.resolve(true);
|
|
379
690
|
return new Promise((resolve) => {
|
|
@@ -675,9 +986,11 @@ async function startSupervisor(options = {}) {
|
|
|
675
986
|
|
|
676
987
|
await new Promise((resolve) => {
|
|
677
988
|
let cleaned = false;
|
|
989
|
+
let autoUpdateManager = null;
|
|
678
990
|
const cleanup = (signal) => {
|
|
679
991
|
if (cleaned) return;
|
|
680
992
|
cleaned = true;
|
|
993
|
+
if (autoUpdateManager) autoUpdateManager.stop();
|
|
681
994
|
writeShutdownReason(signal);
|
|
682
995
|
for (const tunnel of tunnels) tunnel.stop();
|
|
683
996
|
for (const proc of managed) proc.stop();
|
|
@@ -696,6 +1009,15 @@ async function startSupervisor(options = {}) {
|
|
|
696
1009
|
resolve();
|
|
697
1010
|
};
|
|
698
1011
|
|
|
1012
|
+
autoUpdateManager = createAutoUpdateManager({
|
|
1013
|
+
ports,
|
|
1014
|
+
runtimeToken,
|
|
1015
|
+
tunnels,
|
|
1016
|
+
foreground: options.foreground,
|
|
1017
|
+
requestRestart: cleanup,
|
|
1018
|
+
});
|
|
1019
|
+
autoUpdateManager.start();
|
|
1020
|
+
|
|
699
1021
|
process.once('SIGINT', () => cleanup('SIGINT'));
|
|
700
1022
|
process.once('SIGTERM', () => cleanup('SIGTERM'));
|
|
701
1023
|
process.once('SIGHUP', () => cleanup('SIGHUP'));
|
package/lib/tunnel-chat.js
CHANGED
|
@@ -113,6 +113,7 @@ function createChatTunnel({ record, foreground = false }) {
|
|
|
113
113
|
let backoffMs = 1000;
|
|
114
114
|
let connectStartedAt = 0;
|
|
115
115
|
let lastGatewayFrameAt = 0;
|
|
116
|
+
const upstreamStreams = new Map();
|
|
116
117
|
|
|
117
118
|
function log(message) {
|
|
118
119
|
if (foreground) console.log(`[chat-tunnel] ${message}`);
|
|
@@ -245,12 +246,19 @@ function createChatTunnel({ record, foreground = false }) {
|
|
|
245
246
|
});
|
|
246
247
|
});
|
|
247
248
|
res.on('end', () => {
|
|
249
|
+
upstreamStreams.delete(frame.req_id);
|
|
248
250
|
send({ type: 'stream_res_end', req_id: frame.req_id });
|
|
249
251
|
});
|
|
252
|
+
res.on('close', () => {
|
|
253
|
+
upstreamStreams.delete(frame.req_id);
|
|
254
|
+
});
|
|
250
255
|
},
|
|
251
256
|
);
|
|
252
257
|
|
|
258
|
+
upstreamStreams.set(frame.req_id, req);
|
|
259
|
+
|
|
253
260
|
req.on('error', (error) => {
|
|
261
|
+
upstreamStreams.delete(frame.req_id);
|
|
254
262
|
send({
|
|
255
263
|
type: 'stream_res_error',
|
|
256
264
|
req_id: frame.req_id,
|
|
@@ -320,6 +328,14 @@ function createChatTunnel({ record, foreground = false }) {
|
|
|
320
328
|
stopped = true;
|
|
321
329
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
322
330
|
stopWatchdog();
|
|
331
|
+
for (const stream of upstreamStreams.values()) {
|
|
332
|
+
try {
|
|
333
|
+
stream.destroy(new Error('chat tunnel stopped'));
|
|
334
|
+
} catch {
|
|
335
|
+
// noop
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
upstreamStreams.clear();
|
|
323
339
|
if (ws) {
|
|
324
340
|
try {
|
|
325
341
|
ws.close();
|
|
@@ -328,6 +344,9 @@ function createChatTunnel({ record, foreground = false }) {
|
|
|
328
344
|
}
|
|
329
345
|
}
|
|
330
346
|
},
|
|
347
|
+
activeCount() {
|
|
348
|
+
return upstreamStreams.size;
|
|
349
|
+
},
|
|
331
350
|
};
|
|
332
351
|
}
|
|
333
352
|
|
package/lib/tunnel-events.js
CHANGED
|
@@ -425,6 +425,9 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
425
425
|
upstreamStreams.delete(frame.req_id);
|
|
426
426
|
send({ type: 'stream_res_end', req_id: frame.req_id });
|
|
427
427
|
});
|
|
428
|
+
res.on('close', () => {
|
|
429
|
+
upstreamStreams.delete(frame.req_id);
|
|
430
|
+
});
|
|
428
431
|
},
|
|
429
432
|
);
|
|
430
433
|
|
|
@@ -609,6 +612,9 @@ function createEventTunnel({ record, foreground = false }) {
|
|
|
609
612
|
}
|
|
610
613
|
}
|
|
611
614
|
},
|
|
615
|
+
activeCount() {
|
|
616
|
+
return upstreamStreams.size + upstreamSockets.size;
|
|
617
|
+
},
|
|
612
618
|
};
|
|
613
619
|
}
|
|
614
620
|
|
package/lib/updater.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('child_process');
|
|
4
|
+
const {
|
|
5
|
+
AMALGM_RUNTIME_LABEL,
|
|
6
|
+
} = require('./paths');
|
|
7
|
+
const {
|
|
8
|
+
npmTagForRuntimeLabel,
|
|
9
|
+
} = require('./runtime-identity');
|
|
10
|
+
|
|
11
|
+
const PACKAGE_VERSION = require('../package.json').version;
|
|
12
|
+
const DEFAULT_VIEW_TIMEOUT_MS = 15_000;
|
|
13
|
+
const DEFAULT_INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
|
|
15
|
+
function parseSemver(value) {
|
|
16
|
+
const raw = String(value || '').trim().replace(/^v/, '');
|
|
17
|
+
const [core, prerelease = ''] = raw.split('-', 2);
|
|
18
|
+
const [major = '0', minor = '0', patch = '0'] = core.split('.');
|
|
19
|
+
return {
|
|
20
|
+
major: Number(major) || 0,
|
|
21
|
+
minor: Number(minor) || 0,
|
|
22
|
+
patch: Number(patch) || 0,
|
|
23
|
+
prerelease,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function comparePrerelease(left, right) {
|
|
28
|
+
if (!left && !right) return 0;
|
|
29
|
+
if (!left) return 1;
|
|
30
|
+
if (!right) return -1;
|
|
31
|
+
|
|
32
|
+
const aParts = left.split('.');
|
|
33
|
+
const bParts = right.split('.');
|
|
34
|
+
const count = Math.max(aParts.length, bParts.length);
|
|
35
|
+
for (let i = 0; i < count; i += 1) {
|
|
36
|
+
const a = aParts[i] || '';
|
|
37
|
+
const b = bParts[i] || '';
|
|
38
|
+
if (a === b) continue;
|
|
39
|
+
if (!a) return -1;
|
|
40
|
+
if (!b) return 1;
|
|
41
|
+
const aNum = /^\d+$/.test(a) ? Number(a) : null;
|
|
42
|
+
const bNum = /^\d+$/.test(b) ? Number(b) : null;
|
|
43
|
+
if (aNum !== null && bNum !== null) return aNum > bNum ? 1 : -1;
|
|
44
|
+
if (aNum !== null && bNum === null) return -1;
|
|
45
|
+
if (aNum === null && bNum !== null) return 1;
|
|
46
|
+
if (a !== b) return a > b ? 1 : -1;
|
|
47
|
+
}
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function compareVersions(left, right) {
|
|
52
|
+
const a = parseSemver(left);
|
|
53
|
+
const b = parseSemver(right);
|
|
54
|
+
for (const key of ['major', 'minor', 'patch']) {
|
|
55
|
+
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
|
|
56
|
+
}
|
|
57
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function npmCommand() {
|
|
61
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function npmInstallTag(options = {}) {
|
|
65
|
+
const explicit = options.tag || options.channel;
|
|
66
|
+
if (explicit && explicit !== true) return String(explicit).replace(/^@/, '');
|
|
67
|
+
return npmTagForRuntimeLabel(options.runtimeLabel || AMALGM_RUNTIME_LABEL);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function npmEnv(env = process.env) {
|
|
71
|
+
return {
|
|
72
|
+
...env,
|
|
73
|
+
npm_config_audit: 'false',
|
|
74
|
+
npm_config_fund: 'false',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function runNpm(args, options = {}) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const child = spawn(npmCommand(), args, {
|
|
81
|
+
env: npmEnv(options.env),
|
|
82
|
+
windowsHide: true,
|
|
83
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
84
|
+
});
|
|
85
|
+
let stdout = '';
|
|
86
|
+
let stderr = '';
|
|
87
|
+
let timedOut = false;
|
|
88
|
+
const onOutput = typeof options.onOutput === 'function' ? options.onOutput : null;
|
|
89
|
+
const timeoutMs = Number(options.timeoutMs || 0);
|
|
90
|
+
const timer = timeoutMs > 0 ? setTimeout(() => {
|
|
91
|
+
timedOut = true;
|
|
92
|
+
try {
|
|
93
|
+
child.kill('SIGTERM');
|
|
94
|
+
} catch {
|
|
95
|
+
// noop
|
|
96
|
+
}
|
|
97
|
+
}, timeoutMs) : null;
|
|
98
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
99
|
+
|
|
100
|
+
child.stdout.on('data', (chunk) => {
|
|
101
|
+
const text = chunk.toString();
|
|
102
|
+
stdout += text;
|
|
103
|
+
if (onOutput) onOutput(text, 'stdout');
|
|
104
|
+
});
|
|
105
|
+
child.stderr.on('data', (chunk) => {
|
|
106
|
+
const text = chunk.toString();
|
|
107
|
+
stderr += text;
|
|
108
|
+
if (onOutput) onOutput(text, 'stderr');
|
|
109
|
+
});
|
|
110
|
+
child.once('error', (error) => {
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
reject(error);
|
|
113
|
+
});
|
|
114
|
+
child.once('close', (status, signal) => {
|
|
115
|
+
if (timer) clearTimeout(timer);
|
|
116
|
+
resolve({ status, signal, stdout, stderr, timedOut });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function npmFailureDetail(result) {
|
|
122
|
+
if (result.timedOut) return 'npm command timed out';
|
|
123
|
+
return String(result.stderr || result.stdout || '').trim();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function npmPackageVersionForTag(tag, options = {}) {
|
|
127
|
+
const result = await runNpm(['view', `amalgm@${tag}`, 'version', '--json'], {
|
|
128
|
+
timeoutMs: options.timeoutMs || DEFAULT_VIEW_TIMEOUT_MS,
|
|
129
|
+
env: options.env,
|
|
130
|
+
});
|
|
131
|
+
if (result.status !== 0) {
|
|
132
|
+
const detail = npmFailureDetail(result);
|
|
133
|
+
throw new Error(`Could not check npm dist-tag "${tag}". ${detail}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const raw = String(result.stdout || '').trim();
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(raw);
|
|
139
|
+
} catch {
|
|
140
|
+
return raw.replace(/^"|"$/g, '');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function installNpmPackageTag(tag, options = {}) {
|
|
145
|
+
const result = await runNpm(['install', '-g', `amalgm@${tag}`], {
|
|
146
|
+
timeoutMs: options.timeoutMs || DEFAULT_INSTALL_TIMEOUT_MS,
|
|
147
|
+
env: options.env,
|
|
148
|
+
onOutput: options.onOutput,
|
|
149
|
+
});
|
|
150
|
+
if (result.status !== 0) {
|
|
151
|
+
const detail = npmFailureDetail(result);
|
|
152
|
+
const status = result.status ?? result.signal ?? 'unknown';
|
|
153
|
+
throw new Error(`npm install -g amalgm@${tag} failed with status ${status}${detail ? `: ${detail}` : ''}`);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function checkForUpdate(options = {}) {
|
|
159
|
+
const tag = npmInstallTag(options);
|
|
160
|
+
const currentVersion = options.currentVersion || PACKAGE_VERSION;
|
|
161
|
+
const targetVersion = await npmPackageVersionForTag(tag, options);
|
|
162
|
+
if (!targetVersion) throw new Error(`npm did not return a version for amalgm@${tag}`);
|
|
163
|
+
return {
|
|
164
|
+
tag,
|
|
165
|
+
currentVersion,
|
|
166
|
+
targetVersion,
|
|
167
|
+
updateAvailable: compareVersions(targetVersion, currentVersion) > 0,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function updatePackage(options = {}) {
|
|
172
|
+
const check = options.check || await checkForUpdate(options);
|
|
173
|
+
if (!check.updateAvailable) return { ...check, updated: false };
|
|
174
|
+
await installNpmPackageTag(check.tag, options);
|
|
175
|
+
return { ...check, updated: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function envDisablesAutoUpdate(env = process.env) {
|
|
179
|
+
const disable = String(env.AMALGM_DISABLE_AUTO_UPDATE || '').trim().toLowerCase();
|
|
180
|
+
if (['1', 'true', 'yes', 'on'].includes(disable)) return true;
|
|
181
|
+
const auto = String(env.AMALGM_AUTO_UPDATE || '').trim().toLowerCase();
|
|
182
|
+
return ['0', 'false', 'no', 'off'].includes(auto);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function autoUpdateSupported(options = {}) {
|
|
186
|
+
const label = options.runtimeLabel || AMALGM_RUNTIME_LABEL;
|
|
187
|
+
return label !== 'local';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function shouldAutoUpdate(options = {}) {
|
|
191
|
+
const env = options.env || process.env;
|
|
192
|
+
return autoUpdateSupported(options) && !envDisablesAutoUpdate(env);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
checkForUpdate,
|
|
197
|
+
compareVersions,
|
|
198
|
+
envDisablesAutoUpdate,
|
|
199
|
+
installNpmPackageTag,
|
|
200
|
+
npmInstallTag,
|
|
201
|
+
npmPackageVersionForTag,
|
|
202
|
+
parseSemver,
|
|
203
|
+
shouldAutoUpdate,
|
|
204
|
+
updatePackage,
|
|
205
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amalgm",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.79",
|
|
4
4
|
"description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"sync-runtime": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
18
18
|
"prepack": "node ../../scripts/sync-npm-package-runtime.mjs",
|
|
19
19
|
"pack:dry": "npm pack --dry-run",
|
|
20
|
-
"check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
|
|
20
|
+
"check": "node --check bin/amalgm.js && node --check lib/auth-store.js && node --check lib/cli.js && node --check lib/paths.js && node --check lib/process-cleanup.js && node --check lib/runtime-identity.js && node --check lib/runtime-manifest.js && node --check lib/service.js && node --check lib/state-migration.js && node --check lib/supervisor.js && node --check lib/tunnel-chat.js && node --check lib/tunnel-events.js && node --check lib/updater.js && node --check runtime/lib/runtime-manifest.js && node --check runtime/scripts/runtime-auth.js && node --check runtime/scripts/proxy-token-store.js && node --check runtime/scripts/local-gateway.js && node --check runtime/scripts/port-monitor.js && node --check runtime/scripts/chat-server.js && node --check runtime/scripts/chat-server/index.js && node --check runtime/scripts/chat-server/config.js && node --check runtime/scripts/chat-core/tooling/native-binaries.js && node --check runtime/scripts/chat-core/tooling/package-import.js && node --check runtime/scripts/chat-core/tooling/runtime-home.js && node --check runtime/scripts/amalgm-mcp/index.js && node --check runtime/scripts/amalgm-mcp/config.js && node --check runtime/scripts/lib/project-paths.js && node --check runtime/scripts/lib/runtime-paths.js"
|
|
21
21
|
},
|
|
22
22
|
"engines": {
|
|
23
23
|
"node": ">=20"
|
|
@@ -50,6 +50,7 @@ class ClaudeAdapter {
|
|
|
50
50
|
mcpServers: toClaudeMcpServers(contract),
|
|
51
51
|
permissionMode: 'bypassPermissions',
|
|
52
52
|
allowDangerouslySkipPermissions: true,
|
|
53
|
+
includePartialMessages: true,
|
|
53
54
|
...(process.env.CHAT_CORE_DEBUG_CLAUDE === '1'
|
|
54
55
|
? { debug: true, debugFile: path.join(runtime.runtimeHome, 'claude-debug.log') }
|
|
55
56
|
: {}),
|
|
@@ -95,6 +95,14 @@ class ChatCore {
|
|
|
95
95
|
};
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
idleStatus() {
|
|
99
|
+
const activeTurns = typeof this.turns.activeCount === 'function' ? this.turns.activeCount() : 0;
|
|
100
|
+
return {
|
|
101
|
+
idle: activeTurns === 0,
|
|
102
|
+
active_turns: activeTurns,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
98
106
|
async finalizeActiveTurn(sessionId) {
|
|
99
107
|
const entry = this.turns.active(sessionId);
|
|
100
108
|
if (!entry) return false;
|
|
@@ -133,6 +133,7 @@ function ensureClaudeTextState(state) {
|
|
|
133
133
|
if (!state) return null;
|
|
134
134
|
if (!state.claudeTextByIndex) state.claudeTextByIndex = new Map();
|
|
135
135
|
if (!state.claudeReasoningByIndex) state.claudeReasoningByIndex = new Map();
|
|
136
|
+
if (!state.claudeBlockIndexByType) state.claudeBlockIndexByType = new Map();
|
|
136
137
|
return state;
|
|
137
138
|
}
|
|
138
139
|
|
|
@@ -141,10 +142,34 @@ function blockIndex(value) {
|
|
|
141
142
|
return Number.isInteger(index) && index >= 0 ? index : null;
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
function
|
|
145
|
-
|
|
145
|
+
function indexedTextKey(messageKey, index) {
|
|
146
|
+
const key = blockIndex(index);
|
|
147
|
+
if (key === null) return null;
|
|
148
|
+
return `${messageKey || 'current'}:${key}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function snapshotBlockType(block) {
|
|
152
|
+
if (block?.type === 'thinking' || block?.type === 'redacted_thinking') return 'reasoning';
|
|
153
|
+
return String(block?.type || 'unknown');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function rememberSnapshotBlockIndex(state, messageKey, block, index) {
|
|
146
157
|
const textState = ensureClaudeTextState(state);
|
|
147
158
|
const key = blockIndex(index);
|
|
159
|
+
if (!textState || key === null) return;
|
|
160
|
+
textState.claudeBlockIndexByType.set(`${messageKey || 'current'}:${snapshotBlockType(block)}`, key);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function snapshotBlockIndex(state, messageKey, block, fallback) {
|
|
164
|
+
const textState = ensureClaudeTextState(state);
|
|
165
|
+
const known = textState?.claudeBlockIndexByType?.get(`${messageKey || 'current'}:${snapshotBlockType(block)}`);
|
|
166
|
+
return known ?? fallback;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function emitIndexedTextDelta(state, messageKey, index, kind, delta, providerSessionId, raw) {
|
|
170
|
+
if (!delta) return [];
|
|
171
|
+
const textState = ensureClaudeTextState(state);
|
|
172
|
+
const key = indexedTextKey(messageKey, index);
|
|
148
173
|
if (textState && key !== null) {
|
|
149
174
|
const map = kind === 'reasoning' ? textState.claudeReasoningByIndex : textState.claudeTextByIndex;
|
|
150
175
|
map.set(key, `${map.get(key) || ''}${delta}`);
|
|
@@ -159,11 +184,13 @@ function emitIndexedTextSnapshot(block, field, kind, providerSessionId, state, r
|
|
|
159
184
|
if (!text) return [];
|
|
160
185
|
let delta = text;
|
|
161
186
|
const textState = ensureClaudeTextState(state);
|
|
162
|
-
const key =
|
|
187
|
+
const key = indexedTextKey(block.messageKey, block.index);
|
|
163
188
|
if (textState && key !== null) {
|
|
164
189
|
const map = kind === 'reasoning' ? textState.claudeReasoningByIndex : textState.claudeTextByIndex;
|
|
165
190
|
const streamed = map.get(key) || '';
|
|
166
|
-
delta = streamed
|
|
191
|
+
delta = streamed
|
|
192
|
+
? (text === streamed ? '' : (text.startsWith(streamed) ? text.slice(streamed.length) : text))
|
|
193
|
+
: text;
|
|
167
194
|
map.set(key, text);
|
|
168
195
|
}
|
|
169
196
|
if (!delta) return [];
|
|
@@ -354,18 +381,26 @@ function normalizeClaudeStreamEvent(message, providerSessionId, state) {
|
|
|
354
381
|
if (!event || typeof event !== 'object') return [];
|
|
355
382
|
const raw = eventRaw('claude.sdk.message', `claude/${event.type || 'unknown'}`, message);
|
|
356
383
|
|
|
384
|
+
if (event.type === 'message_start' && event.message?.id) {
|
|
385
|
+
if (state) state.claudeCurrentMessageKey = event.message.id;
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const messageKey = state?.claudeCurrentMessageKey || message?.message?.id || message?.uuid || null;
|
|
390
|
+
|
|
357
391
|
if (event.type === 'content_block_start') {
|
|
358
392
|
const block = event.content_block || event.block;
|
|
393
|
+
rememberSnapshotBlockIndex(state, messageKey, block, event.index);
|
|
359
394
|
if (!block || !TOOL_USE_BLOCK_TYPES.has(block.type)) return [];
|
|
360
395
|
return startOrUpdateClaudeTool({ ...block, index: event.index }, providerSessionId, state, raw);
|
|
361
396
|
}
|
|
362
397
|
|
|
363
398
|
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
|
364
|
-
return emitIndexedTextDelta(state, event.index, 'text', event.delta.text, providerSessionId, raw);
|
|
399
|
+
return emitIndexedTextDelta(state, messageKey, event.index, 'text', event.delta.text, providerSessionId, raw);
|
|
365
400
|
}
|
|
366
401
|
|
|
367
402
|
if (event.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
|
|
368
|
-
return emitIndexedTextDelta(state, event.index, 'reasoning', event.delta.thinking, providerSessionId, raw);
|
|
403
|
+
return emitIndexedTextDelta(state, messageKey, event.index, 'reasoning', event.delta.thinking, providerSessionId, raw);
|
|
369
404
|
}
|
|
370
405
|
|
|
371
406
|
if (event.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
|
|
@@ -429,9 +464,11 @@ function normalizeClaudeMessage(message, state) {
|
|
|
429
464
|
|
|
430
465
|
if (message.type === 'assistant') {
|
|
431
466
|
const events = [];
|
|
467
|
+
const messageKey = message.message?.id || message.uuid || state?.claudeCurrentMessageKey || null;
|
|
432
468
|
for (const [index, block] of (message.message?.content || []).entries()) {
|
|
469
|
+
const blockSnapshotIndex = block?.index ?? snapshotBlockIndex(state, messageKey, block, index);
|
|
433
470
|
events.push(...normalizeClaudeContentBlock(
|
|
434
|
-
{ ...block, index },
|
|
471
|
+
{ ...block, index: blockSnapshotIndex, messageKey },
|
|
435
472
|
providerSessionId,
|
|
436
473
|
state,
|
|
437
474
|
eventRaw('claude.sdk.message', 'claude/assistant/content_block', message),
|
|
@@ -38,6 +38,7 @@ function parseRoute(url) {
|
|
|
38
38
|
if (parts[0] === 'chat' && parts.length === 1) return { handler: 'chat' };
|
|
39
39
|
if (parts[0] === 'raw-temp' && parts[1]) return { handler: 'raw-temp', id: parts[1], afterIndex: Number(u.searchParams.get('afterIndex') ?? -1) };
|
|
40
40
|
if (parts[0] === 'active' && parts[1]) return { handler: 'active', id: parts[1] };
|
|
41
|
+
if (parts[0] === 'runtime-idle') return { handler: 'runtime-idle' };
|
|
41
42
|
if (parts[0] === 'stop' && parts[1]) return { handler: 'stop', id: parts[1] };
|
|
42
43
|
if (parts[0] === 'destroy' && parts[1]) return { handler: 'destroy', id: parts[1] };
|
|
43
44
|
if (parts[0] === 'egress' && parts[1]) {
|
|
@@ -72,6 +73,7 @@ function createServer(core = createCore()) {
|
|
|
72
73
|
try {
|
|
73
74
|
if (route.handler === 'healthz') return sendJson(res, 200, { status: 'ok', runtime: 'chat-core', uptime: process.uptime() });
|
|
74
75
|
if (route.handler === 'active') return sendJson(res, 200, core.active(route.id));
|
|
76
|
+
if (route.handler === 'runtime-idle') return sendJson(res, 200, core.idleStatus());
|
|
75
77
|
if (route.handler === 'stop') {
|
|
76
78
|
if (req.method !== 'POST') return sendJson(res, 405, { error: 'Method not allowed' });
|
|
77
79
|
return sendJson(res, 200, { stopped: await core.stop(route.id), codeSessionId: route.id });
|
|
@@ -33,6 +33,15 @@ class TurnStore {
|
|
|
33
33
|
return turnId ? this.turns.get(turnId) || null : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
activeCount() {
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (const turnId of this.activeBySession.values()) {
|
|
39
|
+
const entry = this.turns.get(turnId);
|
|
40
|
+
if (entry && ['streaming', 'cancelling'].includes(entry.status)) count += 1;
|
|
41
|
+
}
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
latest(sessionId) {
|
|
37
46
|
return this.active(sessionId) || this.get(this.latestBySession.get(sessionId));
|
|
38
47
|
}
|
|
@@ -54,3 +54,91 @@ test('claude thinking stream deltas emit reasoning before final snapshot without
|
|
|
54
54
|
}, state);
|
|
55
55
|
assert.deepEqual(finalSnapshot, []);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
test('claude partial stream events emit text deltas', () => {
|
|
59
|
+
const state = { providerSessionId: 'claude-session' };
|
|
60
|
+
|
|
61
|
+
normalizeClaudeMessage({
|
|
62
|
+
type: 'stream_event',
|
|
63
|
+
event: {
|
|
64
|
+
type: 'message_start',
|
|
65
|
+
message: { id: 'msg_streaming' },
|
|
66
|
+
},
|
|
67
|
+
}, state);
|
|
68
|
+
|
|
69
|
+
normalizeClaudeMessage({
|
|
70
|
+
type: 'stream_event',
|
|
71
|
+
event: {
|
|
72
|
+
type: 'content_block_start',
|
|
73
|
+
index: 1,
|
|
74
|
+
content_block: { type: 'text', text: '' },
|
|
75
|
+
},
|
|
76
|
+
}, state);
|
|
77
|
+
|
|
78
|
+
const delta = normalizeClaudeMessage({
|
|
79
|
+
type: 'stream_event',
|
|
80
|
+
event: {
|
|
81
|
+
type: 'content_block_delta',
|
|
82
|
+
index: 1,
|
|
83
|
+
delta: { type: 'text_delta', text: 'streamed' },
|
|
84
|
+
},
|
|
85
|
+
}, state);
|
|
86
|
+
|
|
87
|
+
assert.equal(delta.length, 1);
|
|
88
|
+
assert.equal(delta[0].type, 'text.delta');
|
|
89
|
+
assert.equal(delta[0].text, 'streamed');
|
|
90
|
+
|
|
91
|
+
const finalSnapshot = normalizeClaudeMessage({
|
|
92
|
+
type: 'assistant',
|
|
93
|
+
message: {
|
|
94
|
+
id: 'msg_streaming',
|
|
95
|
+
content: [{ type: 'text', text: 'streamed' }],
|
|
96
|
+
},
|
|
97
|
+
}, state);
|
|
98
|
+
assert.deepEqual(finalSnapshot, []);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('claude assistant snapshots with reused block indexes after tools are not dropped', () => {
|
|
102
|
+
const state = { providerSessionId: 'claude-session' };
|
|
103
|
+
|
|
104
|
+
const intro = normalizeClaudeMessage({
|
|
105
|
+
type: 'assistant',
|
|
106
|
+
message: {
|
|
107
|
+
id: 'msg_intro',
|
|
108
|
+
content: [{ type: 'text', text: 'Let me search first.' }],
|
|
109
|
+
},
|
|
110
|
+
}, state);
|
|
111
|
+
assert.equal(intro.length, 1);
|
|
112
|
+
assert.equal(intro[0].type, 'text.delta');
|
|
113
|
+
assert.equal(intro[0].text, 'Let me search first.');
|
|
114
|
+
|
|
115
|
+
const tool = normalizeClaudeMessage({
|
|
116
|
+
type: 'assistant',
|
|
117
|
+
message: {
|
|
118
|
+
id: 'msg_tool',
|
|
119
|
+
content: [{ type: 'tool_use', id: 'toolu_1', name: 'WebSearch', input: { query: 'news' } }],
|
|
120
|
+
},
|
|
121
|
+
}, state);
|
|
122
|
+
assert.equal(tool.length, 1);
|
|
123
|
+
assert.equal(tool[0].type, 'tool.started');
|
|
124
|
+
|
|
125
|
+
const result = normalizeClaudeMessage({
|
|
126
|
+
type: 'user',
|
|
127
|
+
message: {
|
|
128
|
+
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'results' }],
|
|
129
|
+
},
|
|
130
|
+
}, state);
|
|
131
|
+
assert.equal(result.length, 1);
|
|
132
|
+
assert.equal(result[0].type, 'tool.completed');
|
|
133
|
+
|
|
134
|
+
const finalAnswer = normalizeClaudeMessage({
|
|
135
|
+
type: 'assistant',
|
|
136
|
+
message: {
|
|
137
|
+
id: 'msg_final',
|
|
138
|
+
content: [{ type: 'text', text: 'Here are the results.' }],
|
|
139
|
+
},
|
|
140
|
+
}, state);
|
|
141
|
+
assert.equal(finalAnswer.length, 1);
|
|
142
|
+
assert.equal(finalAnswer[0].type, 'text.delta');
|
|
143
|
+
assert.equal(finalAnswer[0].text, 'Here are the results.');
|
|
144
|
+
});
|
|
@@ -117,3 +117,43 @@ test('POST /chat streams from the same indexed raw chunks as /raw-temp', async (
|
|
|
117
117
|
await new Promise((resolve) => server.close(resolve));
|
|
118
118
|
}
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
test('GET /runtime-idle reports active turn count', async () => {
|
|
122
|
+
process.env.AMALGM_RUNTIME_AUTH = 'disabled';
|
|
123
|
+
|
|
124
|
+
const core = new ChatCore({
|
|
125
|
+
db: {
|
|
126
|
+
mergeSessionMetadata: async () => {},
|
|
127
|
+
},
|
|
128
|
+
runtime: runtime(),
|
|
129
|
+
turns: new TurnStore(),
|
|
130
|
+
options: {
|
|
131
|
+
beginTurn: async () => {},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
const server = createServer(core);
|
|
135
|
+
const baseUrl = await listen(server);
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
let res = await fetch(`${baseUrl}/runtime-idle`);
|
|
139
|
+
assert.equal(res.status, 200);
|
|
140
|
+
assert.deepEqual(await res.json(), { idle: true, active_turns: 0 });
|
|
141
|
+
|
|
142
|
+
const entry = core.turns.start({
|
|
143
|
+
sessionId: 'session-test',
|
|
144
|
+
turnId: 'turn-test',
|
|
145
|
+
assistantMessageId: 'assistant-test',
|
|
146
|
+
userMessageId: 'user-test',
|
|
147
|
+
});
|
|
148
|
+
res = await fetch(`${baseUrl}/runtime-idle`);
|
|
149
|
+
assert.equal(res.status, 200);
|
|
150
|
+
assert.deepEqual(await res.json(), { idle: false, active_turns: 1 });
|
|
151
|
+
|
|
152
|
+
core.turns.mark(entry.turnId, 'complete');
|
|
153
|
+
res = await fetch(`${baseUrl}/runtime-idle`);
|
|
154
|
+
assert.equal(res.status, 200);
|
|
155
|
+
assert.deepEqual(await res.json(), { idle: true, active_turns: 0 });
|
|
156
|
+
} finally {
|
|
157
|
+
await new Promise((resolve) => server.close(resolve));
|
|
158
|
+
}
|
|
159
|
+
});
|