@addai/node 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auto-update.d.ts +139 -0
- package/dist/auto-update.js +261 -0
- package/dist/capabilities.d.ts +9 -0
- package/dist/capabilities.js +4 -0
- package/dist/codex-spawn.d.ts +8 -0
- package/dist/codex-spawn.js +51 -4
- package/dist/command-runner.d.ts +3 -1
- package/dist/grok-spawn.d.ts +4 -0
- package/dist/grok-spawn.js +13 -0
- package/dist/heartbeat.d.ts +1 -0
- package/dist/heartbeat.js +12 -0
- package/dist/index.js +35 -1
- package/dist/machine-metrics.d.ts +72 -0
- package/dist/machine-metrics.js +175 -0
- package/dist/self-update.d.ts +40 -2
- package/dist/self-update.js +107 -14
- package/dist/store.d.ts +4 -1
- package/dist/store.js +22 -2
- package/dist/tui/dashboard.js +8 -0
- package/package.json +1 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { type HarnessId } from './harness-registry';
|
|
2
|
+
import { type LaunchMode } from './self-update';
|
|
3
|
+
/**
|
|
4
|
+
* A stable per-node offset into the hour, derived from the daemon token.
|
|
5
|
+
*
|
|
6
|
+
* The fleet has to be spread across the hour rather than converging on :00 —
|
|
7
|
+
* with no soak window, the stagger is the ONLY thing limiting how fast a bad
|
|
8
|
+
* publish reaches everyone. Deriving it from the token (rather than random)
|
|
9
|
+
* means a node keeps its slot across restarts, including the restart an
|
|
10
|
+
* auto-roll itself causes, which would otherwise re-converge every rolled
|
|
11
|
+
* node onto the same minute.
|
|
12
|
+
*/
|
|
13
|
+
export declare function tickOffsetMs(token: string): number;
|
|
14
|
+
/**
|
|
15
|
+
* Milliseconds until this node's next slot, measured against the wall clock
|
|
16
|
+
* rather than against boot time — so a daemon that restarts twice an hour
|
|
17
|
+
* keeps firing on its own slot instead of drifting to a new one each time.
|
|
18
|
+
*/
|
|
19
|
+
export declare function msUntilNextTick(nowMs: number, offsetMs: number): number;
|
|
20
|
+
export type SkipReason = 'disabled' | 'busy' | 'breaker';
|
|
21
|
+
/**
|
|
22
|
+
* The guards, in the order they are cheapest to check.
|
|
23
|
+
*
|
|
24
|
+
* `busy` is a skip, NOT a drain: an auto-update is elective and there is
|
|
25
|
+
* always another hour, whereas draining would take the node out of rotation
|
|
26
|
+
* for up to ten minutes every time the tick happened to land mid-turn. A node
|
|
27
|
+
* that is never idle never auto-updates, and gets rolled by hand.
|
|
28
|
+
*/
|
|
29
|
+
export declare function shouldRun(s: {
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
busy: boolean;
|
|
32
|
+
breakerOpen: boolean;
|
|
33
|
+
}): SkipReason | null;
|
|
34
|
+
export interface HarnessUpgrade {
|
|
35
|
+
id: HarnessId;
|
|
36
|
+
npmPackage: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Which harnesses this tick will `npm i -g …@latest`.
|
|
40
|
+
*
|
|
41
|
+
* Two rules, both deliberate:
|
|
42
|
+
* - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
|
|
43
|
+
* so it is never touched here — a truthful skip beats a fake upgrade.
|
|
44
|
+
* - installed only. The tick upgrades; it does not provision. Installing a
|
|
45
|
+
* harness nobody asked for is a different feature, and one with a cost
|
|
46
|
+
* (disk, a login prompt that never comes) the user never opted into.
|
|
47
|
+
*/
|
|
48
|
+
export declare function planHarnessUpgrades(installed: HarnessId[]): HarnessUpgrade[];
|
|
49
|
+
export interface RollDecision {
|
|
50
|
+
roll: boolean;
|
|
51
|
+
/** Why not, when roll is false. Logged, and surfaced in capabilities. */
|
|
52
|
+
reason?: string;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether to roll the daemon itself.
|
|
56
|
+
*
|
|
57
|
+
* A source checkout is refused for the same reason a remote version bump is
|
|
58
|
+
* (see planRoll): its version comes from the working tree, so installing and
|
|
59
|
+
* restarting would come back up on exactly the same bits — a no-op wearing a
|
|
60
|
+
* success. Harnesses on such a node are still upgraded; only this half is off.
|
|
61
|
+
*/
|
|
62
|
+
export declare function planAutoRoll(current: string, latest: string | null, mode: LaunchMode): RollDecision;
|
|
63
|
+
export interface HarnessOutcome {
|
|
64
|
+
from?: string;
|
|
65
|
+
to?: string;
|
|
66
|
+
/** Where the binary resolves NOW. You chose npm-for-every-npm-able-harness,
|
|
67
|
+
* so on a machine whose Claude Code came from Anthropic's native installer
|
|
68
|
+
* npm has just put a second copy somewhere else — possibly shadowing the
|
|
69
|
+
* first. The path is what makes that visible instead of silent. */
|
|
70
|
+
path?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
}
|
|
73
|
+
export interface AutoUpdateState {
|
|
74
|
+
enabled: boolean;
|
|
75
|
+
last_checked_at?: string;
|
|
76
|
+
last_result?: 'ok' | 'skipped' | 'failed';
|
|
77
|
+
skipped_reason?: SkipReason;
|
|
78
|
+
/** Set when a roll actually happened — written by the daemon that came up
|
|
79
|
+
* after it, from the restart handoff. */
|
|
80
|
+
runtime?: {
|
|
81
|
+
from: string;
|
|
82
|
+
to: string;
|
|
83
|
+
wanted?: string;
|
|
84
|
+
} | null;
|
|
85
|
+
harnesses?: Record<string, HarnessOutcome>;
|
|
86
|
+
error?: string;
|
|
87
|
+
}
|
|
88
|
+
/** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
|
|
89
|
+
* page already reads — no new RPC, no new column, no polling. */
|
|
90
|
+
export declare function autoUpdateState(): AutoUpdateState;
|
|
91
|
+
/** Called by index.ts on boot when the handoff it consumed had no command id,
|
|
92
|
+
* i.e. the roll was ours rather than a button press. */
|
|
93
|
+
export declare function recordAutoRoll(r: {
|
|
94
|
+
from: string;
|
|
95
|
+
to: string;
|
|
96
|
+
wanted?: string;
|
|
97
|
+
}): void;
|
|
98
|
+
/** Only the part of a capabilities probe this module reads. Kept structural
|
|
99
|
+
* rather than importing CapabilitiesShape, which would put capabilities.ts on
|
|
100
|
+
* both ends of an import cycle (it reads autoUpdateState from here). */
|
|
101
|
+
type ProbeShape = Partial<Record<HarnessId, {
|
|
102
|
+
version?: string;
|
|
103
|
+
available?: boolean;
|
|
104
|
+
}>>;
|
|
105
|
+
export interface AutoUpdateDeps {
|
|
106
|
+
/** The daemon token — only used to derive this node's slot. */
|
|
107
|
+
token: () => string | null;
|
|
108
|
+
/** True while this node holds a child process for a live run. */
|
|
109
|
+
isBusy: () => boolean;
|
|
110
|
+
/** The daemon-wide Supabase circuit breaker. */
|
|
111
|
+
breakerOpen: () => boolean;
|
|
112
|
+
/** Whether the server says auto-update is on for this node. */
|
|
113
|
+
enabled: () => boolean;
|
|
114
|
+
/** Full capabilities probe — used for before/after harness versions. */
|
|
115
|
+
probe: () => Promise<ProbeShape>;
|
|
116
|
+
/** Push a heartbeat so Studio sees the new versions without waiting. */
|
|
117
|
+
beat: () => Promise<void>;
|
|
118
|
+
/** The same closure `update_runtime` uses. No command id: nobody asked. */
|
|
119
|
+
roll: (opts: {
|
|
120
|
+
version: string;
|
|
121
|
+
}) => Promise<void>;
|
|
122
|
+
/** This daemon's own version. */
|
|
123
|
+
version: string;
|
|
124
|
+
/** `npm i -g <spec>`; null on success. Injectable so the orchestration below
|
|
125
|
+
* — the before/after diff, the busy re-check — can be tested without
|
|
126
|
+
* actually installing anything. */
|
|
127
|
+
install?: (spec: string) => Promise<string | null>;
|
|
128
|
+
/** What npm calls latest for a package, or null if we couldn't ask. */
|
|
129
|
+
latest?: (pkg: string) => Promise<string | null>;
|
|
130
|
+
/** How this daemon was launched. Injectable because under a test runner
|
|
131
|
+
* argv[1] is the runner, which would read as `source` and mask the roll. */
|
|
132
|
+
mode?: () => LaunchMode;
|
|
133
|
+
}
|
|
134
|
+
/** One pass. Exported so `ainode` can be made to do it on demand later, and
|
|
135
|
+
* so a test can drive it without waiting an hour. */
|
|
136
|
+
export declare function runOnce(d: AutoUpdateDeps): Promise<void>;
|
|
137
|
+
export declare function start(d: AutoUpdateDeps): void;
|
|
138
|
+
export declare function stop(): void;
|
|
139
|
+
export {};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Hourly auto-update — this node keeps itself, and its harnesses, current.
|
|
3
|
+
//
|
|
4
|
+
// Until now a node only moved when somebody clicked. `update_runtime` issued
|
|
5
|
+
// by hand from Entity Studio was the ONLY thing that ever rolled a daemon,
|
|
6
|
+
// which is how the fleet came to sit on 0.2.51/0.2.52/0.2.53 for days after
|
|
7
|
+
// 0.2.55 shipped. Harnesses were worse: Studio offers Install only while a
|
|
8
|
+
// harness reads `missing`, so once Claude Code was on a node it could never
|
|
9
|
+
// be upgraded from anywhere.
|
|
10
|
+
//
|
|
11
|
+
// The schedule lives here rather than in a server-side cron because a node
|
|
12
|
+
// asleep, behind NAT, or between pairings then needs no special case — the
|
|
13
|
+
// daemon already wakes on its own for reclaim, diskguard and sleep detection.
|
|
14
|
+
//
|
|
15
|
+
// Everything the tick needs from the rest of the daemon is INJECTED by
|
|
16
|
+
// index.ts (roll, probe, beat, isBusy, isEnabled). That keeps this module's
|
|
17
|
+
// imports down to the registry and the npm helpers, so the decisions below
|
|
18
|
+
// are testable without booting a daemon — and avoids the
|
|
19
|
+
// auto-update → heartbeat → capabilities → auto-update cycle that importing
|
|
20
|
+
// them directly would create.
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.tickOffsetMs = tickOffsetMs;
|
|
23
|
+
exports.msUntilNextTick = msUntilNextTick;
|
|
24
|
+
exports.shouldRun = shouldRun;
|
|
25
|
+
exports.planHarnessUpgrades = planHarnessUpgrades;
|
|
26
|
+
exports.planAutoRoll = planAutoRoll;
|
|
27
|
+
exports.autoUpdateState = autoUpdateState;
|
|
28
|
+
exports.recordAutoRoll = recordAutoRoll;
|
|
29
|
+
exports.runOnce = runOnce;
|
|
30
|
+
exports.start = start;
|
|
31
|
+
exports.stop = stop;
|
|
32
|
+
const harness_registry_1 = require("./harness-registry");
|
|
33
|
+
const self_update_1 = require("./self-update");
|
|
34
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
35
|
+
/** Nothing auto-rolls in the first few minutes of a boot. A node that just
|
|
36
|
+
* came up may BE the result of a roll (or of one that went badly), and
|
|
37
|
+
* rolling again immediately would turn a bad publish into a restart loop. */
|
|
38
|
+
const BOOT_GRACE_MS = 5 * 60 * 1000;
|
|
39
|
+
/* ── when ─────────────────────────────────────────────────────────────── */
|
|
40
|
+
/**
|
|
41
|
+
* A stable per-node offset into the hour, derived from the daemon token.
|
|
42
|
+
*
|
|
43
|
+
* The fleet has to be spread across the hour rather than converging on :00 —
|
|
44
|
+
* with no soak window, the stagger is the ONLY thing limiting how fast a bad
|
|
45
|
+
* publish reaches everyone. Deriving it from the token (rather than random)
|
|
46
|
+
* means a node keeps its slot across restarts, including the restart an
|
|
47
|
+
* auto-roll itself causes, which would otherwise re-converge every rolled
|
|
48
|
+
* node onto the same minute.
|
|
49
|
+
*/
|
|
50
|
+
function tickOffsetMs(token) {
|
|
51
|
+
// FNV-1a. Not cryptographic — it only has to spread evenly and be stable.
|
|
52
|
+
let h = 0x811c9dc5;
|
|
53
|
+
for (let i = 0; i < token.length; i++) {
|
|
54
|
+
h ^= token.charCodeAt(i);
|
|
55
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
56
|
+
}
|
|
57
|
+
return h % HOUR_MS;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Milliseconds until this node's next slot, measured against the wall clock
|
|
61
|
+
* rather than against boot time — so a daemon that restarts twice an hour
|
|
62
|
+
* keeps firing on its own slot instead of drifting to a new one each time.
|
|
63
|
+
*/
|
|
64
|
+
function msUntilNextTick(nowMs, offsetMs) {
|
|
65
|
+
const since = (((nowMs - offsetMs) % HOUR_MS) + HOUR_MS) % HOUR_MS;
|
|
66
|
+
const wait = HOUR_MS - since;
|
|
67
|
+
// since === 0 lands exactly on the slot; wait a full hour rather than
|
|
68
|
+
// firing twice for the same slot.
|
|
69
|
+
return wait === 0 ? HOUR_MS : wait;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The guards, in the order they are cheapest to check.
|
|
73
|
+
*
|
|
74
|
+
* `busy` is a skip, NOT a drain: an auto-update is elective and there is
|
|
75
|
+
* always another hour, whereas draining would take the node out of rotation
|
|
76
|
+
* for up to ten minutes every time the tick happened to land mid-turn. A node
|
|
77
|
+
* that is never idle never auto-updates, and gets rolled by hand.
|
|
78
|
+
*/
|
|
79
|
+
function shouldRun(s) {
|
|
80
|
+
if (!s.enabled)
|
|
81
|
+
return 'disabled';
|
|
82
|
+
if (s.breakerOpen)
|
|
83
|
+
return 'breaker';
|
|
84
|
+
if (s.busy)
|
|
85
|
+
return 'busy';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Which harnesses this tick will `npm i -g …@latest`.
|
|
90
|
+
*
|
|
91
|
+
* Two rules, both deliberate:
|
|
92
|
+
* - npm-able only. grok's `npmPackage` is null (xAI ships its own installer),
|
|
93
|
+
* so it is never touched here — a truthful skip beats a fake upgrade.
|
|
94
|
+
* - installed only. The tick upgrades; it does not provision. Installing a
|
|
95
|
+
* harness nobody asked for is a different feature, and one with a cost
|
|
96
|
+
* (disk, a login prompt that never comes) the user never opted into.
|
|
97
|
+
*/
|
|
98
|
+
function planHarnessUpgrades(installed) {
|
|
99
|
+
const present = new Set(installed);
|
|
100
|
+
return harness_registry_1.HARNESS_IDS
|
|
101
|
+
.filter(id => present.has(id) && harness_registry_1.HARNESSES[id].npmPackage !== null)
|
|
102
|
+
.map(id => ({ id, npmPackage: harness_registry_1.HARNESSES[id].npmPackage }));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Whether to roll the daemon itself.
|
|
106
|
+
*
|
|
107
|
+
* A source checkout is refused for the same reason a remote version bump is
|
|
108
|
+
* (see planRoll): its version comes from the working tree, so installing and
|
|
109
|
+
* restarting would come back up on exactly the same bits — a no-op wearing a
|
|
110
|
+
* success. Harnesses on such a node are still upgraded; only this half is off.
|
|
111
|
+
*/
|
|
112
|
+
function planAutoRoll(current, latest, mode) {
|
|
113
|
+
if (mode === 'source')
|
|
114
|
+
return { roll: false, reason: 'source checkout — pull and rebuild it there' };
|
|
115
|
+
if (!latest)
|
|
116
|
+
return { roll: false, reason: 'could not read the latest published version' };
|
|
117
|
+
if (latest === current)
|
|
118
|
+
return { roll: false, reason: 'already latest' };
|
|
119
|
+
return { roll: true };
|
|
120
|
+
}
|
|
121
|
+
let state = { enabled: true };
|
|
122
|
+
let isEnabled = () => true;
|
|
123
|
+
/** Read by capabilities.ts so the whole picture rides the heartbeat the AiNode
|
|
124
|
+
* page already reads — no new RPC, no new column, no polling. */
|
|
125
|
+
function autoUpdateState() {
|
|
126
|
+
return { ...state, enabled: isEnabled() };
|
|
127
|
+
}
|
|
128
|
+
/** Called by index.ts on boot when the handoff it consumed had no command id,
|
|
129
|
+
* i.e. the roll was ours rather than a button press. */
|
|
130
|
+
function recordAutoRoll(r) {
|
|
131
|
+
state = { ...state, runtime: r, last_result: 'ok', last_checked_at: new Date().toISOString() };
|
|
132
|
+
}
|
|
133
|
+
let timer = null;
|
|
134
|
+
let stopped = false;
|
|
135
|
+
let deps = null;
|
|
136
|
+
function harnessVersion(probe, id) {
|
|
137
|
+
const p = probe[id];
|
|
138
|
+
return p?.available ? p.version : undefined;
|
|
139
|
+
}
|
|
140
|
+
function installedHarnesses(probe) {
|
|
141
|
+
return harness_registry_1.HARNESS_IDS.filter(id => probe[id]?.available === true);
|
|
142
|
+
}
|
|
143
|
+
/** One pass. Exported so `ainode` can be made to do it on demand later, and
|
|
144
|
+
* so a test can drive it without waiting an hour. */
|
|
145
|
+
async function runOnce(d) {
|
|
146
|
+
const skip = shouldRun({ enabled: d.enabled(), busy: d.isBusy(), breakerOpen: d.breakerOpen() });
|
|
147
|
+
if (skip) {
|
|
148
|
+
state = { ...state, last_checked_at: new Date().toISOString(), last_result: 'skipped', skipped_reason: skip };
|
|
149
|
+
if (skip !== 'breaker')
|
|
150
|
+
console.log(`[auto-update] skipped — ${skip === 'busy' ? 'the node is mid-run' : 'turned off for this node'}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const checkedAt = new Date().toISOString();
|
|
154
|
+
const harnesses = {};
|
|
155
|
+
let failed;
|
|
156
|
+
const install = d.install ?? self_update_1.npmInstallGlobal;
|
|
157
|
+
const latestOf = d.latest ?? self_update_1.npmLatestVersion;
|
|
158
|
+
/* ── harnesses first — nothing restarts, so this half always lands ──── */
|
|
159
|
+
try {
|
|
160
|
+
const before = await d.probe();
|
|
161
|
+
const upgrades = planHarnessUpgrades(installedHarnesses(before));
|
|
162
|
+
for (const u of upgrades) {
|
|
163
|
+
const from = harnessVersion(before, u.id);
|
|
164
|
+
const err = await install(`${u.npmPackage}@latest`);
|
|
165
|
+
harnesses[u.id] = { from, ...(err ? { error: err } : {}) };
|
|
166
|
+
if (err)
|
|
167
|
+
console.error(`[auto-update] ${u.id}: ${err}`);
|
|
168
|
+
}
|
|
169
|
+
if (upgrades.length > 0) {
|
|
170
|
+
// The finders cached their pre-upgrade lookups; a probe through stale
|
|
171
|
+
// caches would report the OLD version and hide the upgrade entirely.
|
|
172
|
+
(0, harness_registry_1.resetBinaryCaches)();
|
|
173
|
+
const after = await d.probe();
|
|
174
|
+
for (const u of upgrades) {
|
|
175
|
+
const to = harnessVersion(after, u.id);
|
|
176
|
+
const path = harness_registry_1.HARNESSES[u.id].findBinary() ?? undefined;
|
|
177
|
+
harnesses[u.id] = { ...harnesses[u.id], to, path };
|
|
178
|
+
if (harnesses[u.id].from !== to) {
|
|
179
|
+
console.log(`[auto-update] ${u.id} ${harnesses[u.id].from ?? '?'} → ${to ?? '?'} (${path ?? 'not resolvable'})`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await d.beat();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
failed = `harness upgrade: ${err.message}`;
|
|
187
|
+
console.error(`[auto-update] ${failed}`);
|
|
188
|
+
}
|
|
189
|
+
state = {
|
|
190
|
+
...state,
|
|
191
|
+
last_checked_at: checkedAt,
|
|
192
|
+
last_result: failed ? 'failed' : 'ok',
|
|
193
|
+
skipped_reason: undefined,
|
|
194
|
+
harnesses,
|
|
195
|
+
error: failed,
|
|
196
|
+
};
|
|
197
|
+
/* ── then the daemon itself ─────────────────────────────────────────── */
|
|
198
|
+
const mode = d.mode ? d.mode() : (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
|
|
199
|
+
const latest = mode === 'source' ? null : await latestOf(self_update_1.PACKAGE_NAME);
|
|
200
|
+
const decision = planAutoRoll(d.version, latest, mode);
|
|
201
|
+
if (!decision.roll) {
|
|
202
|
+
if (decision.reason !== 'already latest')
|
|
203
|
+
console.log(`[auto-update] not rolling — ${decision.reason}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Re-check busy: the harness upgrades above can take minutes, and a run may
|
|
207
|
+
// well have arrived while npm was working. Rolling now would guillotine it
|
|
208
|
+
// at the drain ceiling for no reason — there is another hour.
|
|
209
|
+
if (d.isBusy()) {
|
|
210
|
+
state = { ...state, last_result: 'skipped', skipped_reason: 'busy' };
|
|
211
|
+
console.log('[auto-update] a run arrived during the harness pass — leaving the roll for next hour');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
console.log(`[auto-update] rolling ${d.version} → ${latest}`);
|
|
215
|
+
try {
|
|
216
|
+
// This does not return: the roll drains, hands over, and exits. The
|
|
217
|
+
// daemon that comes up completes the story from the handoff file.
|
|
218
|
+
await d.roll({ version: latest });
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
state = { ...state, last_result: 'failed', error: `roll: ${err.message}` };
|
|
222
|
+
console.error(`[auto-update] roll failed — staying on ${d.version}:`, err.message);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function schedule(firstRun) {
|
|
226
|
+
if (stopped || !deps)
|
|
227
|
+
return;
|
|
228
|
+
const token = deps.token() ?? '';
|
|
229
|
+
const offset = tickOffsetMs(token);
|
|
230
|
+
let wait = msUntilNextTick(Date.now(), offset);
|
|
231
|
+
if (firstRun)
|
|
232
|
+
wait = Math.max(wait, BOOT_GRACE_MS);
|
|
233
|
+
timer = setTimeout(() => {
|
|
234
|
+
void (async () => {
|
|
235
|
+
try {
|
|
236
|
+
await runOnce(deps);
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
console.error('[auto-update] tick crashed:', err.message);
|
|
240
|
+
}
|
|
241
|
+
schedule(false);
|
|
242
|
+
})();
|
|
243
|
+
}, wait);
|
|
244
|
+
// Never keep the process alive just to auto-update.
|
|
245
|
+
timer.unref?.();
|
|
246
|
+
}
|
|
247
|
+
function start(d) {
|
|
248
|
+
if (timer || stopped)
|
|
249
|
+
return;
|
|
250
|
+
deps = d;
|
|
251
|
+
isEnabled = d.enabled;
|
|
252
|
+
state = { ...state, enabled: d.enabled() };
|
|
253
|
+
schedule(true);
|
|
254
|
+
}
|
|
255
|
+
function stop() {
|
|
256
|
+
stopped = true;
|
|
257
|
+
if (timer) {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
timer = null;
|
|
260
|
+
}
|
|
261
|
+
}
|
package/dist/capabilities.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type AutostartState } from './autostart';
|
|
2
|
+
import { type AutoUpdateState } from './auto-update';
|
|
3
|
+
import { type MachineSample } from './machine-metrics';
|
|
2
4
|
interface AuthShape {
|
|
3
5
|
authed: boolean;
|
|
4
6
|
account?: string;
|
|
@@ -42,6 +44,13 @@ interface CapabilitiesShape {
|
|
|
42
44
|
* rather than a column of its own — same channel Studio already reads the
|
|
43
45
|
* harness grid from. */
|
|
44
46
|
autostart?: AutostartState;
|
|
47
|
+
/** CPU / memory / disk / runs at the moment of this probe. The AiNode page
|
|
48
|
+
* has read this key since it was written; nothing produced it until now. */
|
|
49
|
+
machine?: MachineSample;
|
|
50
|
+
/** What the hourly auto-update last did — versions moved, skips, failures.
|
|
51
|
+
* Same channel, same reason: without it the AiNode page is somewhere the
|
|
52
|
+
* version silently changes with no explanation. */
|
|
53
|
+
auto_update?: AutoUpdateState;
|
|
45
54
|
}
|
|
46
55
|
/** Codex's credential store: $CODEX_HOME (or ~/.codex) + /auth.json. */
|
|
47
56
|
export declare function codexHome(): string;
|
package/dist/capabilities.js
CHANGED
|
@@ -53,6 +53,8 @@ const codex_binary_1 = require("./codex-binary");
|
|
|
53
53
|
const win_1 = require("./win");
|
|
54
54
|
const harness_registry_1 = require("./harness-registry");
|
|
55
55
|
const autostart_1 = require("./autostart");
|
|
56
|
+
const auto_update_1 = require("./auto-update");
|
|
57
|
+
const machine_metrics_1 = require("./machine-metrics");
|
|
56
58
|
function readDaemonVersion() {
|
|
57
59
|
try {
|
|
58
60
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -398,6 +400,8 @@ async function probeCapabilities() {
|
|
|
398
400
|
return {
|
|
399
401
|
daemon_version: readDaemonVersion(),
|
|
400
402
|
autostart: (0, autostart_1.status)(),
|
|
403
|
+
auto_update: (0, auto_update_1.autoUpdateState)(),
|
|
404
|
+
machine: (0, machine_metrics_1.sampleMachine)(),
|
|
401
405
|
claude: deco('claude', claude),
|
|
402
406
|
codex: deco('codex', codex),
|
|
403
407
|
kimi: deco('kimi', kimi),
|
package/dist/codex-spawn.d.ts
CHANGED
|
@@ -25,6 +25,14 @@ export interface CodexInput {
|
|
|
25
25
|
* Mirrors the claude --strict-mcp-config behavior. */
|
|
26
26
|
mcpServers?: CodexMcpServer[];
|
|
27
27
|
}
|
|
28
|
+
/** Build a config.toml that declares only the supplied MCP servers and
|
|
29
|
+
* return the CODEX_HOME directory plus any env vars the child needs
|
|
30
|
+
* (bearer tokens for remote servers). Caller is responsible for cleanup
|
|
31
|
+
* (we drop it under os.tmpdir() so it gets reaped). */
|
|
32
|
+
export declare function writeCodexHome(servers: CodexMcpServer[], workingDirectory: string): {
|
|
33
|
+
home: string;
|
|
34
|
+
extraEnv: Record<string, string>;
|
|
35
|
+
};
|
|
28
36
|
export interface CodexHandle {
|
|
29
37
|
pid: number | undefined;
|
|
30
38
|
threadId: string | null;
|
package/dist/codex-spawn.js
CHANGED
|
@@ -40,6 +40,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
40
40
|
};
|
|
41
41
|
})();
|
|
42
42
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.writeCodexHome = writeCodexHome;
|
|
43
44
|
exports.codexEffort = codexEffort;
|
|
44
45
|
exports.spawnCodex = spawnCodex;
|
|
45
46
|
const child_process_1 = require("child_process");
|
|
@@ -54,15 +55,58 @@ const events_1 = require("./events");
|
|
|
54
55
|
function tomlString(s) {
|
|
55
56
|
return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
|
|
56
57
|
}
|
|
58
|
+
/** Env var name carrying an HTTP MCP's bearer token into the codex process.
|
|
59
|
+
* Only the NAME goes in config.toml — the value rides on the child env, so
|
|
60
|
+
* the token never lands on disk. */
|
|
61
|
+
const bearerEnvVar = (slug) => `AINODE_MCP_${slug.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}_TOKEN`;
|
|
57
62
|
/** Build a config.toml that declares only the supplied MCP servers and
|
|
58
|
-
* return the directory
|
|
59
|
-
*
|
|
63
|
+
* return the CODEX_HOME directory plus any env vars the child needs
|
|
64
|
+
* (bearer tokens for remote servers). Caller is responsible for cleanup
|
|
65
|
+
* (we drop it under os.tmpdir() so it gets reaped). */
|
|
60
66
|
function writeCodexHome(servers, workingDirectory) {
|
|
61
67
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'entities-codex-'));
|
|
68
|
+
const extraEnv = {};
|
|
62
69
|
let toml = '# Generated by ainode — sandboxes codex against host config\n\n';
|
|
63
70
|
for (const s of servers) {
|
|
64
71
|
if (!s.slug || !s.command)
|
|
65
72
|
continue;
|
|
73
|
+
// Remote MCP servers. Registry rows store command='http'|'sse' with
|
|
74
|
+
// args=[url]; codex spells that `url = "..."`, NOT command/args. Writing
|
|
75
|
+
// the row verbatim made codex try to exec a binary called `http`, so the
|
|
76
|
+
// server never started and its tools silently vanished — which is how a
|
|
77
|
+
// provider-ladder hop to codex dropped an entity's GitHub access without
|
|
78
|
+
// any error anywhere. The claude path (install.ts) always handled this;
|
|
79
|
+
// codex and grok did not.
|
|
80
|
+
if (s.command === 'http' || s.command === 'sse') {
|
|
81
|
+
const url = Array.isArray(s.args) && typeof s.args[0] === 'string' ? s.args[0] : null;
|
|
82
|
+
if (!url) {
|
|
83
|
+
console.error(`[codex] MCP ${s.slug}: ${s.command} server has no URL — skipped`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const name = tomlString(s.slug).slice(1, -1);
|
|
87
|
+
toml += `[mcp_servers.${name}]\n`;
|
|
88
|
+
toml += `url = ${tomlString(url)}\n`;
|
|
89
|
+
// codex reads the bearer from its OWN environment (--env is stdio-only),
|
|
90
|
+
// so the value goes on the child env and only the var name in the file.
|
|
91
|
+
const creds = Object.entries(s.env || {})
|
|
92
|
+
.filter(([k, v]) => typeof v === 'string' && v.length > 0 && !k.startsWith('OAUTH_REFRESH'));
|
|
93
|
+
const bearer = creds.find(([k]) => k === 'OAUTH_ACCESS_TOKEN') ?? creds[0];
|
|
94
|
+
if (bearer) {
|
|
95
|
+
const varName = bearerEnvVar(s.slug);
|
|
96
|
+
extraEnv[varName] = String(bearer[1]);
|
|
97
|
+
toml += `bearer_token_env_var = ${tomlString(varName)}\n`;
|
|
98
|
+
if (creds.length > 1) {
|
|
99
|
+
// codex carries one bearer and no arbitrary headers. Anything that
|
|
100
|
+
// authenticates with several custom headers is only partly wired,
|
|
101
|
+
// and saying so beats a server that 401s for no visible reason.
|
|
102
|
+
console.error(`[codex] MCP ${s.slug}: only ${bearer[0]} is forwarded as a bearer token; ` +
|
|
103
|
+
`codex cannot send custom headers, so ${creds.length - 1} other credential ` +
|
|
104
|
+
`field(s) were dropped`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
toml += '\n';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
66
110
|
// On native Windows, registry commands like `npx` are .cmd shims that
|
|
67
111
|
// codex can't CreateProcess directly — wrap with `cmd /c` (no-op on POSIX).
|
|
68
112
|
const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args : []);
|
|
@@ -109,7 +153,7 @@ function writeCodexHome(servers, workingDirectory) {
|
|
|
109
153
|
if (fs.existsSync(sessionSkillsDir)) {
|
|
110
154
|
(0, win_1.linkDir)(sessionSkillsDir, path.join(dir, 'skills'));
|
|
111
155
|
}
|
|
112
|
-
return dir;
|
|
156
|
+
return { home: dir, extraEnv };
|
|
113
157
|
}
|
|
114
158
|
function lineToEvents(line) {
|
|
115
159
|
const type = typeof line.type === 'string' ? line.type : '';
|
|
@@ -194,7 +238,7 @@ function spawnCodex(input) {
|
|
|
194
238
|
// Sandbox codex against an isolated CODEX_HOME so the host's
|
|
195
239
|
// ~/.codex/config.toml mcp_servers don't leak in. TERM=dumb keeps
|
|
196
240
|
// codex's TTY heuristics happy (no fancy terminal output).
|
|
197
|
-
const codexHome = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
|
|
241
|
+
const { home: codexHome, extraEnv: mcpBearerEnv } = writeCodexHome(input.mcpServers ?? [], input.workingDirectory);
|
|
198
242
|
// Register the scratch dir with DiskGuard: marks it active (so a
|
|
199
243
|
// concurrent sweep won't evict a live run) and writes a PID lock so a
|
|
200
244
|
// different runtime process can tell it's still owned. dispose(true)
|
|
@@ -205,6 +249,9 @@ function spawnCodex(input) {
|
|
|
205
249
|
...process.env,
|
|
206
250
|
TERM: 'dumb',
|
|
207
251
|
CODEX_HOME: codexHome,
|
|
252
|
+
// Bearer tokens for remote (http/sse) MCP servers. config.toml names these
|
|
253
|
+
// vars via bearer_token_env_var; the values only ever live here.
|
|
254
|
+
...mcpBearerEnv,
|
|
208
255
|
};
|
|
209
256
|
// Resolve binary path before spawn. Previously called spawn('codex', ...)
|
|
210
257
|
// bare, which threw ENOENT under launchd / desktop-app PATH inheritance
|
package/dist/command-runner.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export type RestartHook = (opts: {
|
|
2
|
-
|
|
2
|
+
/** Absent when the hourly auto-update is the one rolling: nobody issued a
|
|
3
|
+
* command, so there is nothing to close on the other side. */
|
|
4
|
+
commandId?: string;
|
|
3
5
|
version: string;
|
|
4
6
|
/** Bounce this daemon on the version it is already running: no npm
|
|
5
7
|
* install, no version change. See runUpdateRuntime. */
|
package/dist/grok-spawn.d.ts
CHANGED
|
@@ -38,4 +38,8 @@ export interface GrokHandle {
|
|
|
38
38
|
onActivity(cb: () => void): void;
|
|
39
39
|
done: Promise<number>;
|
|
40
40
|
}
|
|
41
|
+
/** Write the entity's MCP servers as a project-scoped `<cwd>/.grok/config.toml`.
|
|
42
|
+
* Project scope only supports `[mcp_servers]`, which is exactly what we need.
|
|
43
|
+
* Returns the config path (for logging) or null when there are no servers. */
|
|
44
|
+
export declare function writeProjectMcpConfig(workingDirectory: string, servers: GrokMcpServer[]): string | null;
|
|
41
45
|
export declare function spawnGrok(input: GrokInput): GrokHandle;
|
package/dist/grok-spawn.js
CHANGED
|
@@ -64,6 +64,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
64
64
|
};
|
|
65
65
|
})();
|
|
66
66
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
67
|
+
exports.writeProjectMcpConfig = writeProjectMcpConfig;
|
|
67
68
|
exports.spawnGrok = spawnGrok;
|
|
68
69
|
const child_process_1 = require("child_process");
|
|
69
70
|
const fs = __importStar(require("fs"));
|
|
@@ -158,6 +159,18 @@ function writeProjectMcpConfig(workingDirectory, servers) {
|
|
|
158
159
|
}
|
|
159
160
|
const lines = [];
|
|
160
161
|
for (const s of usable) {
|
|
162
|
+
// Remote MCP servers (registry command='http'|'sse', args=[url]) have no
|
|
163
|
+
// verified grok config spelling, and writing the row verbatim is worse
|
|
164
|
+
// than skipping: grok would try to exec a binary called `http`, the
|
|
165
|
+
// server would never start, and its tools would vanish with no error —
|
|
166
|
+
// the exact silent failure that cost an entity its GitHub access after a
|
|
167
|
+
// provider-ladder hop. Skip it, and say so loudly enough to be findable.
|
|
168
|
+
if (s.command === 'http' || s.command === 'sse') {
|
|
169
|
+
console.error(`[grok] MCP ${s.slug}: remote ${s.command} servers are not supported by the grok ` +
|
|
170
|
+
`adapter — skipping. Its tools will be ABSENT from this run. Use claude (or codex, ` +
|
|
171
|
+
`which supports url + bearer_token_env_var) for entities that depend on it.`);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
161
174
|
// `cmd /c` wrapper for npm-shim commands on native Windows (no-op on POSIX).
|
|
162
175
|
const wrapped = (0, win_1.wrapMcpCommandForPlatform)(s.command, Array.isArray(s.args) ? s.args.map(String) : []);
|
|
163
176
|
lines.push(`[mcp_servers.${tomlStr(s.slug)}]`);
|
package/dist/heartbeat.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export declare function setPendingCommandsHook(cb: () => void): void;
|
|
2
|
+
export declare function autoUpdateEnabled(): boolean;
|
|
2
3
|
/** Announce a clean shutdown so the server flips us offline immediately
|
|
3
4
|
* (status='offline') instead of waiting ~90s for last_seen_at to age out —
|
|
4
5
|
* lets a planned restart fail over instantly. Best-effort; the stale-window
|
package/dist/heartbeat.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// is paired.
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.setPendingCommandsHook = setPendingCommandsHook;
|
|
7
|
+
exports.autoUpdateEnabled = autoUpdateEnabled;
|
|
7
8
|
exports.goOffline = goOffline;
|
|
8
9
|
exports.beat = beat;
|
|
9
10
|
exports.start = start;
|
|
@@ -21,6 +22,15 @@ let onPendingCommands = null;
|
|
|
21
22
|
function setPendingCommandsHook(cb) {
|
|
22
23
|
onPendingCommands = cb;
|
|
23
24
|
}
|
|
25
|
+
// Is hourly auto-update armed for this node? It rides the heartbeat rather
|
|
26
|
+
// than getting an RPC of its own — the daemon is already talking to the server
|
|
27
|
+
// every 30s, and the answer is one boolean on the node's own row.
|
|
28
|
+
//
|
|
29
|
+
// Default ON, and specifically ON when the field is ABSENT: a server that has
|
|
30
|
+
// not had the migration yet must not read as "everybody opted out". Only an
|
|
31
|
+
// explicit `false` turns it off.
|
|
32
|
+
let autoUpdateArmed = true;
|
|
33
|
+
function autoUpdateEnabled() { return autoUpdateArmed; }
|
|
24
34
|
async function tick() {
|
|
25
35
|
// Skip if the daemon-wide circuit breaker is open. Heartbeats are
|
|
26
36
|
// safe to miss — vault marks the runtime offline after ~90s; the
|
|
@@ -41,6 +51,8 @@ async function tick() {
|
|
|
41
51
|
if (res && typeof res.pending_commands === 'number' && res.pending_commands > 0) {
|
|
42
52
|
onPendingCommands?.();
|
|
43
53
|
}
|
|
54
|
+
if (res && typeof res.auto_update === 'boolean')
|
|
55
|
+
autoUpdateArmed = res.auto_update;
|
|
44
56
|
}
|
|
45
57
|
catch (err) {
|
|
46
58
|
if (err instanceof supabase_client_1.RpcError && err.status === 401) {
|
package/dist/index.js
CHANGED
|
@@ -49,6 +49,9 @@ const pairing_1 = require("./pairing");
|
|
|
49
49
|
const heartbeat_1 = require("./heartbeat");
|
|
50
50
|
const command_runner_1 = require("./command-runner");
|
|
51
51
|
const self_update_1 = require("./self-update");
|
|
52
|
+
const auto_update_1 = require("./auto-update");
|
|
53
|
+
const machine_metrics_1 = require("./machine-metrics");
|
|
54
|
+
const capabilities_1 = require("./capabilities");
|
|
52
55
|
const autostart_1 = require("./autostart");
|
|
53
56
|
const sleep_detector_1 = require("./sleep-detector");
|
|
54
57
|
const claude_config_1 = require("./claude-config");
|
|
@@ -143,6 +146,11 @@ async function start(argv = []) {
|
|
|
143
146
|
// server reports queued install/login commands, the command runner
|
|
144
147
|
// drains them.
|
|
145
148
|
(0, heartbeat_1.setPendingCommandsHook)(command_runner_1.wake);
|
|
149
|
+
// So each machine sample can say how many runs the box was holding at the
|
|
150
|
+
// time — "pinned at 90%" and "pinned at 90% running four things" are
|
|
151
|
+
// different stories. Hooked rather than imported so capabilities.ts needn't
|
|
152
|
+
// pull in request-pump, and through it session-runner.
|
|
153
|
+
(0, machine_metrics_1.setRunsInFlightHook)(() => (0, request_pump_1.activeRequestIdList)().length);
|
|
146
154
|
(0, heartbeat_1.start)();
|
|
147
155
|
// If we are the daemon a remote `update_runtime` restarted INTO, close out
|
|
148
156
|
// that command now — with the version we actually came up as. The process
|
|
@@ -211,6 +219,7 @@ async function start(argv = []) {
|
|
|
211
219
|
(0, projects_1.stop)();
|
|
212
220
|
(0, request_pump_1.stop)();
|
|
213
221
|
(0, heartbeat_1.stop)();
|
|
222
|
+
(0, auto_update_1.stop)();
|
|
214
223
|
sleepMonitor.stop();
|
|
215
224
|
diskGuard.stop();
|
|
216
225
|
// Graceful drain: wait up to drainTimeoutMs for in-flight requests
|
|
@@ -254,7 +263,7 @@ async function start(argv = []) {
|
|
|
254
263
|
// to the same bits (which matters for npx, whose cache dir is per-version)
|
|
255
264
|
// and npm is never called — nothing to fetch, and nothing that can fail
|
|
256
265
|
// offline.
|
|
257
|
-
|
|
266
|
+
const performRestart = async ({ commandId, version, restartOnly }) => {
|
|
258
267
|
const mode = (0, self_update_1.detectLaunchMode)(process.argv[1] ?? '');
|
|
259
268
|
const plan = (0, self_update_1.planRespawn)(mode, process.argv[1] ?? '', version, process.execPath, process.argv.slice(2));
|
|
260
269
|
const supervised = (0, autostart_1.isSupervised)();
|
|
@@ -293,6 +302,21 @@ async function start(argv = []) {
|
|
|
293
302
|
? `[restart] standing down for launchd to restart us (${what}); exiting`
|
|
294
303
|
: `[restart] handed over to ${plan.file} (${what}); exiting`);
|
|
295
304
|
setTimeout(() => process.exit(0), 250).unref?.();
|
|
305
|
+
};
|
|
306
|
+
(0, command_runner_1.setRestartHook)(performRestart);
|
|
307
|
+
// Hourly auto-update — the node keeps itself and its harnesses current
|
|
308
|
+
// without anybody clicking. It rolls through the very same closure the
|
|
309
|
+
// manual button does, minus the command id, so a hand roll and an auto
|
|
310
|
+
// roll cannot drift apart.
|
|
311
|
+
(0, auto_update_1.start)({
|
|
312
|
+
token: () => (0, store_1.readPairing)()?.daemonToken ?? null,
|
|
313
|
+
isBusy: () => (0, request_pump_1.activeRequestIdList)().length > 0,
|
|
314
|
+
breakerOpen: () => (0, supabase_client_1.rpcShouldSkip)(),
|
|
315
|
+
enabled: heartbeat_1.autoUpdateEnabled,
|
|
316
|
+
probe: () => (0, capabilities_1.probeCapabilities)(),
|
|
317
|
+
beat: () => (0, heartbeat_1.beat)(),
|
|
318
|
+
roll: ({ version }) => performRestart({ version }),
|
|
319
|
+
version: VERSION,
|
|
296
320
|
});
|
|
297
321
|
process.once('SIGINT', () => { stop().finally(() => process.exit(0)); });
|
|
298
322
|
process.once('SIGTERM', () => { stop().finally(() => process.exit(0)); });
|
|
@@ -392,6 +416,16 @@ async function finalizeRestartHandoff() {
|
|
|
392
416
|
const changed = h.from_version !== VERSION;
|
|
393
417
|
const wanted = h.target_version;
|
|
394
418
|
const missed = wanted !== 'latest' && wanted !== VERSION;
|
|
419
|
+
// No command id means the hourly auto-update rolled us, not a button. There
|
|
420
|
+
// is nothing to close; the outcome goes into capabilities instead, which the
|
|
421
|
+
// next heartbeat (seconds away) carries to the AiNode page.
|
|
422
|
+
if (!h.command_id) {
|
|
423
|
+
(0, auto_update_1.recordAutoRoll)({ from: h.from_version, to: VERSION, ...(missed ? { wanted } : {}) });
|
|
424
|
+
console.log(missed
|
|
425
|
+
? `[auto-update] restarted, but came up as ${VERSION} (wanted ${wanted})`
|
|
426
|
+
: `[auto-update] back up as ${VERSION} (was ${h.from_version})`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
395
429
|
try {
|
|
396
430
|
await (0, supabase_client_1.rpc)('runtime_command_update', {
|
|
397
431
|
p_token: t,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as os from 'os';
|
|
2
|
+
export interface MachineSample {
|
|
3
|
+
/** Logical cores. */
|
|
4
|
+
cpus?: number;
|
|
5
|
+
/** Physical memory, MB. */
|
|
6
|
+
mem_total_mb?: number;
|
|
7
|
+
/** Memory in use, 0-100. */
|
|
8
|
+
mem_used_pct?: number;
|
|
9
|
+
/** Processor busy across all cores since the previous sample, 0-100.
|
|
10
|
+
* Absent on the first sample of a daemon's life — there is nothing to
|
|
11
|
+
* measure against yet, and a made-up first value would be a lie that
|
|
12
|
+
* lands in a graph. */
|
|
13
|
+
cpu_used_pct?: number;
|
|
14
|
+
/** 1-minute load average per core. Zero on Windows, where the OS has no
|
|
15
|
+
* such concept and Node reports [0,0,0]. */
|
|
16
|
+
load_per_cpu?: number;
|
|
17
|
+
/** Scratch volume in use, 0-100 — the same signal the health gate refuses
|
|
18
|
+
* new runs on at 97%. A filling disk is the failure that has actually
|
|
19
|
+
* bitten prod, so it belongs on the same timeline as the rest. */
|
|
20
|
+
disk_used_pct?: number;
|
|
21
|
+
/** Agent runs this node was holding at the moment of the sample. Turns
|
|
22
|
+
* "the box was pinned at 90%" into "because it was running four things". */
|
|
23
|
+
runs?: number;
|
|
24
|
+
/** Seconds since the machine booted. */
|
|
25
|
+
uptime_s?: number;
|
|
26
|
+
}
|
|
27
|
+
export interface CpuTimes {
|
|
28
|
+
idle: number;
|
|
29
|
+
total: number;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Smallest interval, in summed CPU-milliseconds across all cores, we will
|
|
33
|
+
* report a busy figure for.
|
|
34
|
+
*
|
|
35
|
+
* Without a floor, two readings a millisecond apart can straddle a single
|
|
36
|
+
* scheduler tick: idle advances by 0, total by ~1, and the honest arithmetic
|
|
37
|
+
* says 100% busy. That is noise, not measurement, and it lands in the graph as
|
|
38
|
+
* a spike that never happened — which is exactly how it was found, by a test
|
|
39
|
+
* that took two samples back to back and got 100 roughly one run in twelve.
|
|
40
|
+
*
|
|
41
|
+
* 100ms across all cores is ~12ms of wall time on an 8-core box, far below the
|
|
42
|
+
* 30s heartbeat this actually runs at, so no real sample is ever refused.
|
|
43
|
+
*/
|
|
44
|
+
export declare const MIN_SAMPLE_MS = 100;
|
|
45
|
+
/** Summed CPU time across every core, in the units Node reports. */
|
|
46
|
+
export declare function readCpuTimes(cpus: os.CpuInfo[]): CpuTimes;
|
|
47
|
+
/**
|
|
48
|
+
* Busy percentage between two cumulative readings.
|
|
49
|
+
*
|
|
50
|
+
* Deliberately delta-based rather than derived from loadavg: load is a queue
|
|
51
|
+
* length, not a utilisation, it means different things on different kernels,
|
|
52
|
+
* and on Windows it is always zero. Deltas of the counters Node already
|
|
53
|
+
* exposes give a real 0-100 on every platform we run on.
|
|
54
|
+
*
|
|
55
|
+
* Returns null when the comparison is meaningless — no previous reading, or a
|
|
56
|
+
* total that did not advance (same tick) or went backwards (core count
|
|
57
|
+
* changed under us, a VM migrated). A null is "we don't know", which the graph
|
|
58
|
+
* shows as a gap; inventing 0% would draw an idle machine that wasn't.
|
|
59
|
+
*/
|
|
60
|
+
export declare function cpuUsedPct(prev: CpuTimes | null, now: CpuTimes): number | null;
|
|
61
|
+
/** Memory in use as a percentage. Note this counts the page cache as used on
|
|
62
|
+
* Linux, the same way `free` does before you read the -/+ buffers line — it
|
|
63
|
+
* is the number every other tool shows, so it is the one to match. */
|
|
64
|
+
export declare function memUsedPct(totalBytes: number, freeBytes: number): number | null;
|
|
65
|
+
export declare function setRunsInFlightHook(fn: () => number): void;
|
|
66
|
+
/** Reset between tests. */
|
|
67
|
+
export declare function resetCpuBaseline(): void;
|
|
68
|
+
/**
|
|
69
|
+
* One reading of the machine. Never throws: every field is independently
|
|
70
|
+
* best-effort, and a sample with holes in it beats no heartbeat at all.
|
|
71
|
+
*/
|
|
72
|
+
export declare function sampleMachine(): MachineSample;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// What the machine underneath the daemon is actually doing.
|
|
3
|
+
//
|
|
4
|
+
// Entity Studio's AiNode page has read `capabilities.machine` for cores,
|
|
5
|
+
// memory, load and uptime since it was written — but no version of this daemon
|
|
6
|
+
// has ever produced that key, so those readouts have always been dashes and
|
|
7
|
+
// the Memory tile has always silently fallen back to showing the pairing date.
|
|
8
|
+
// This is the producing half.
|
|
9
|
+
//
|
|
10
|
+
// Sampled on the capabilities probe (every heartbeat, ~30s). Everything here
|
|
11
|
+
// is cheap and synchronous: three `os` calls and one statfs. Nothing may throw
|
|
12
|
+
// — a metrics failure must never cost the heartbeat, because the heartbeat is
|
|
13
|
+
// what keeps the node marked online.
|
|
14
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
17
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
18
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
19
|
+
}
|
|
20
|
+
Object.defineProperty(o, k2, desc);
|
|
21
|
+
}) : (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
o[k2] = m[k];
|
|
24
|
+
}));
|
|
25
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
26
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
27
|
+
}) : function(o, v) {
|
|
28
|
+
o["default"] = v;
|
|
29
|
+
});
|
|
30
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
31
|
+
var ownKeys = function(o) {
|
|
32
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
33
|
+
var ar = [];
|
|
34
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
35
|
+
return ar;
|
|
36
|
+
};
|
|
37
|
+
return ownKeys(o);
|
|
38
|
+
};
|
|
39
|
+
return function (mod) {
|
|
40
|
+
if (mod && mod.__esModule) return mod;
|
|
41
|
+
var result = {};
|
|
42
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
43
|
+
__setModuleDefault(result, mod);
|
|
44
|
+
return result;
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.MIN_SAMPLE_MS = void 0;
|
|
49
|
+
exports.readCpuTimes = readCpuTimes;
|
|
50
|
+
exports.cpuUsedPct = cpuUsedPct;
|
|
51
|
+
exports.memUsedPct = memUsedPct;
|
|
52
|
+
exports.setRunsInFlightHook = setRunsInFlightHook;
|
|
53
|
+
exports.resetCpuBaseline = resetCpuBaseline;
|
|
54
|
+
exports.sampleMachine = sampleMachine;
|
|
55
|
+
const os = __importStar(require("os"));
|
|
56
|
+
const node_health_1 = require("./node-health");
|
|
57
|
+
const paths_1 = require("./paths");
|
|
58
|
+
/**
|
|
59
|
+
* Smallest interval, in summed CPU-milliseconds across all cores, we will
|
|
60
|
+
* report a busy figure for.
|
|
61
|
+
*
|
|
62
|
+
* Without a floor, two readings a millisecond apart can straddle a single
|
|
63
|
+
* scheduler tick: idle advances by 0, total by ~1, and the honest arithmetic
|
|
64
|
+
* says 100% busy. That is noise, not measurement, and it lands in the graph as
|
|
65
|
+
* a spike that never happened — which is exactly how it was found, by a test
|
|
66
|
+
* that took two samples back to back and got 100 roughly one run in twelve.
|
|
67
|
+
*
|
|
68
|
+
* 100ms across all cores is ~12ms of wall time on an 8-core box, far below the
|
|
69
|
+
* 30s heartbeat this actually runs at, so no real sample is ever refused.
|
|
70
|
+
*/
|
|
71
|
+
exports.MIN_SAMPLE_MS = 100;
|
|
72
|
+
/** Summed CPU time across every core, in the units Node reports. */
|
|
73
|
+
function readCpuTimes(cpus) {
|
|
74
|
+
let idle = 0;
|
|
75
|
+
let total = 0;
|
|
76
|
+
for (const c of cpus) {
|
|
77
|
+
for (const key of Object.keys(c.times)) {
|
|
78
|
+
total += c.times[key];
|
|
79
|
+
}
|
|
80
|
+
idle += c.times.idle;
|
|
81
|
+
}
|
|
82
|
+
return { idle, total };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Busy percentage between two cumulative readings.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately delta-based rather than derived from loadavg: load is a queue
|
|
88
|
+
* length, not a utilisation, it means different things on different kernels,
|
|
89
|
+
* and on Windows it is always zero. Deltas of the counters Node already
|
|
90
|
+
* exposes give a real 0-100 on every platform we run on.
|
|
91
|
+
*
|
|
92
|
+
* Returns null when the comparison is meaningless — no previous reading, or a
|
|
93
|
+
* total that did not advance (same tick) or went backwards (core count
|
|
94
|
+
* changed under us, a VM migrated). A null is "we don't know", which the graph
|
|
95
|
+
* shows as a gap; inventing 0% would draw an idle machine that wasn't.
|
|
96
|
+
*/
|
|
97
|
+
function cpuUsedPct(prev, now) {
|
|
98
|
+
if (!prev)
|
|
99
|
+
return null;
|
|
100
|
+
const total = now.total - prev.total;
|
|
101
|
+
const idle = now.idle - prev.idle;
|
|
102
|
+
if (total < exports.MIN_SAMPLE_MS || idle < 0)
|
|
103
|
+
return null;
|
|
104
|
+
const pct = (1 - idle / total) * 100;
|
|
105
|
+
return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
|
|
106
|
+
}
|
|
107
|
+
/** Memory in use as a percentage. Note this counts the page cache as used on
|
|
108
|
+
* Linux, the same way `free` does before you read the -/+ buffers line — it
|
|
109
|
+
* is the number every other tool shows, so it is the one to match. */
|
|
110
|
+
function memUsedPct(totalBytes, freeBytes) {
|
|
111
|
+
if (!Number.isFinite(totalBytes) || totalBytes <= 0)
|
|
112
|
+
return null;
|
|
113
|
+
const pct = (1 - freeBytes / totalBytes) * 100;
|
|
114
|
+
return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
|
|
115
|
+
}
|
|
116
|
+
/* ── live sampling ────────────────────────────────────────────────────── */
|
|
117
|
+
let prevCpu = null;
|
|
118
|
+
/** How many runs this node is holding. Wired by index.ts rather than imported,
|
|
119
|
+
* so capabilities.ts does not have to pull in request-pump (and through it
|
|
120
|
+
* session-runner) just to count them. */
|
|
121
|
+
let runsInFlight = null;
|
|
122
|
+
function setRunsInFlightHook(fn) { runsInFlight = fn; }
|
|
123
|
+
/** Reset between tests. */
|
|
124
|
+
function resetCpuBaseline() { prevCpu = null; }
|
|
125
|
+
/**
|
|
126
|
+
* One reading of the machine. Never throws: every field is independently
|
|
127
|
+
* best-effort, and a sample with holes in it beats no heartbeat at all.
|
|
128
|
+
*/
|
|
129
|
+
function sampleMachine() {
|
|
130
|
+
const s = {};
|
|
131
|
+
try {
|
|
132
|
+
const cpus = os.cpus();
|
|
133
|
+
if (cpus.length > 0) {
|
|
134
|
+
s.cpus = cpus.length;
|
|
135
|
+
const now = readCpuTimes(cpus);
|
|
136
|
+
const pct = cpuUsedPct(prevCpu, now);
|
|
137
|
+
if (pct !== null)
|
|
138
|
+
s.cpu_used_pct = pct;
|
|
139
|
+
prevCpu = now;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch { /* no cpu info — the other fields still stand */ }
|
|
143
|
+
try {
|
|
144
|
+
const total = os.totalmem();
|
|
145
|
+
s.mem_total_mb = Math.round(total / (1024 * 1024));
|
|
146
|
+
const pct = memUsedPct(total, os.freemem());
|
|
147
|
+
if (pct !== null)
|
|
148
|
+
s.mem_used_pct = pct;
|
|
149
|
+
}
|
|
150
|
+
catch { /* skip */ }
|
|
151
|
+
try {
|
|
152
|
+
const [one] = os.loadavg();
|
|
153
|
+
const cores = s.cpus ?? 0;
|
|
154
|
+
if (cores > 0 && Number.isFinite(one))
|
|
155
|
+
s.load_per_cpu = Math.round((one / cores) * 100) / 100;
|
|
156
|
+
}
|
|
157
|
+
catch { /* skip */ }
|
|
158
|
+
try {
|
|
159
|
+
const disk = (0, node_health_1.readDiskUsedPct)(paths_1.RUNTIME_HOME);
|
|
160
|
+
if (disk !== null)
|
|
161
|
+
s.disk_used_pct = Math.round(disk * 10) / 10;
|
|
162
|
+
}
|
|
163
|
+
catch { /* skip */ }
|
|
164
|
+
try {
|
|
165
|
+
s.runs = runsInFlight?.() ?? 0;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
s.runs = 0;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
s.uptime_s = Math.round(os.uptime());
|
|
172
|
+
}
|
|
173
|
+
catch { /* skip */ }
|
|
174
|
+
return s;
|
|
175
|
+
}
|
package/dist/self-update.d.ts
CHANGED
|
@@ -53,6 +53,23 @@ export interface RollPlan {
|
|
|
53
53
|
/** Non-null = refuse before anything drains, with this explanation. */
|
|
54
54
|
refusal: string | null;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Is this something we are willing to hand to `npm i -g @addai/node@…`?
|
|
58
|
+
*
|
|
59
|
+
* npm accepts far more than a version after the `@`: a tarball URL, a
|
|
60
|
+
* `github:user/repo`, a `file:` path — each of which installs code of the
|
|
61
|
+
* caller's choosing, which this daemon then re-execs as its own binary. A
|
|
62
|
+
* remotely-supplied spec is therefore restricted to a plain semver or a
|
|
63
|
+
* dist-tag, and everything else is refused before npm is ever invoked.
|
|
64
|
+
*
|
|
65
|
+
* This is the second lock. The first is the RLS policy on runtime_commands,
|
|
66
|
+
* which was found comparing `input ->> 'restart_only'` (text) while this file
|
|
67
|
+
* compared `=== true` (boolean) — so the string "true" read as a harmless
|
|
68
|
+
* restart to the database and as a roll-to-anything here, giving anyone a node
|
|
69
|
+
* was merely SHARED with arbitrary code execution on the owner's machine.
|
|
70
|
+
* The policy is fixed; this makes the same mistake unexploitable next time.
|
|
71
|
+
*/
|
|
72
|
+
export declare function isSafeVersionSpec(v: string): boolean;
|
|
56
73
|
/**
|
|
57
74
|
* Read an `update_runtime` command's input into what this node should do.
|
|
58
75
|
*
|
|
@@ -67,7 +84,12 @@ export declare function planRoll(input: {
|
|
|
67
84
|
restart_only?: boolean;
|
|
68
85
|
} | null, mode: LaunchMode, currentVersion: string): RollPlan;
|
|
69
86
|
export interface RestartHandoff {
|
|
70
|
-
|
|
87
|
+
/** Absent when the roll was the hourly auto-update rather than a button:
|
|
88
|
+
* there is no command to close, so the daemon that comes up records the
|
|
89
|
+
* outcome in capabilities instead. The property that matters survives
|
|
90
|
+
* either way — it is the NEW process that reports, so a restart that
|
|
91
|
+
* never comes back still never reports success. */
|
|
92
|
+
command_id?: string;
|
|
71
93
|
from_version: string;
|
|
72
94
|
target_version: string;
|
|
73
95
|
mode: LaunchMode;
|
|
@@ -87,11 +109,27 @@ export declare function writeHandoff(runtimeDir: string, h: RestartHandoff): voi
|
|
|
87
109
|
/** Reads and DELETES the handoff — it must never be replayed twice. */
|
|
88
110
|
export declare function takeHandoff(runtimeDir: string): RestartHandoff | null;
|
|
89
111
|
/**
|
|
90
|
-
* `npm i -g <
|
|
112
|
+
* `npm i -g <spec>` for any package. Returns null on success, else the reason.
|
|
91
113
|
* Never throws — a failed install must leave the CURRENT daemon running
|
|
92
114
|
* rather than take the node down.
|
|
115
|
+
*
|
|
116
|
+
* On a classic Mac with a root-owned /usr/local prefix the first attempt dies
|
|
117
|
+
* EACCES, so we retry into the daemon's own prefix — the same fallback
|
|
118
|
+
* command-runner's interactive install uses, and the same one the binary
|
|
119
|
+
* finders already search. Without it, auto-update would fail silently forever
|
|
120
|
+
* on exactly the nodes that needed a human least.
|
|
93
121
|
*/
|
|
122
|
+
export declare function npmInstallGlobal(spec: string, timeoutMs?: number): Promise<string | null>;
|
|
123
|
+
/** `npm i -g @addai/node@<version>`. Thin wrapper kept for the roll paths. */
|
|
94
124
|
export declare function installGlobal(targetVersion: string, timeoutMs?: number): Promise<string | null>;
|
|
125
|
+
/**
|
|
126
|
+
* What npm currently calls `latest` for a package, or null if we couldn't ask.
|
|
127
|
+
*
|
|
128
|
+
* Null is deliberately NOT treated as "up to date" by callers — it means the
|
|
129
|
+
* question went unanswered (offline, registry blip), and an unanswered
|
|
130
|
+
* question must never masquerade as a clean bill of health.
|
|
131
|
+
*/
|
|
132
|
+
export declare function npmLatestVersion(pkg: string, timeoutMs?: number): Promise<string | null>;
|
|
95
133
|
/**
|
|
96
134
|
* Spawn the replacement daemon fully detached, so it survives this process's
|
|
97
135
|
* exit. stdio is redirected to the daemon log (inheriting our stdio would tie
|
package/dist/self-update.js
CHANGED
|
@@ -55,16 +55,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
55
55
|
exports.PACKAGE_NAME = void 0;
|
|
56
56
|
exports.detectLaunchMode = detectLaunchMode;
|
|
57
57
|
exports.planRespawn = planRespawn;
|
|
58
|
+
exports.isSafeVersionSpec = isSafeVersionSpec;
|
|
58
59
|
exports.planRoll = planRoll;
|
|
59
60
|
exports.handoffPath = handoffPath;
|
|
60
61
|
exports.writeHandoff = writeHandoff;
|
|
61
62
|
exports.takeHandoff = takeHandoff;
|
|
63
|
+
exports.npmInstallGlobal = npmInstallGlobal;
|
|
62
64
|
exports.installGlobal = installGlobal;
|
|
65
|
+
exports.npmLatestVersion = npmLatestVersion;
|
|
63
66
|
exports.spawnReplacement = spawnReplacement;
|
|
64
67
|
const child_process_1 = require("child_process");
|
|
65
68
|
const fs = __importStar(require("fs"));
|
|
66
69
|
const path = __importStar(require("path"));
|
|
67
70
|
const win_1 = require("./win");
|
|
71
|
+
const paths_1 = require("./paths");
|
|
68
72
|
exports.PACKAGE_NAME = '@addai/node';
|
|
69
73
|
/**
|
|
70
74
|
* Classify the launch from the running script's path.
|
|
@@ -113,6 +117,29 @@ userArgs = []) {
|
|
|
113
117
|
canChangeVersion: mode === 'global',
|
|
114
118
|
};
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Is this something we are willing to hand to `npm i -g @addai/node@…`?
|
|
122
|
+
*
|
|
123
|
+
* npm accepts far more than a version after the `@`: a tarball URL, a
|
|
124
|
+
* `github:user/repo`, a `file:` path — each of which installs code of the
|
|
125
|
+
* caller's choosing, which this daemon then re-execs as its own binary. A
|
|
126
|
+
* remotely-supplied spec is therefore restricted to a plain semver or a
|
|
127
|
+
* dist-tag, and everything else is refused before npm is ever invoked.
|
|
128
|
+
*
|
|
129
|
+
* This is the second lock. The first is the RLS policy on runtime_commands,
|
|
130
|
+
* which was found comparing `input ->> 'restart_only'` (text) while this file
|
|
131
|
+
* compared `=== true` (boolean) — so the string "true" read as a harmless
|
|
132
|
+
* restart to the database and as a roll-to-anything here, giving anyone a node
|
|
133
|
+
* was merely SHARED with arbitrary code execution on the owner's machine.
|
|
134
|
+
* The policy is fixed; this makes the same mistake unexploitable next time.
|
|
135
|
+
*/
|
|
136
|
+
function isSafeVersionSpec(v) {
|
|
137
|
+
// 1.2.3 / 1.2.3-beta.1
|
|
138
|
+
if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(v))
|
|
139
|
+
return true;
|
|
140
|
+
// a dist-tag: latest, next, canary…
|
|
141
|
+
return /^[a-z][a-z0-9-]{0,31}$/.test(v);
|
|
142
|
+
}
|
|
116
143
|
/**
|
|
117
144
|
* Read an `update_runtime` command's input into what this node should do.
|
|
118
145
|
*
|
|
@@ -128,9 +155,11 @@ function planRoll(input, mode, currentVersion) {
|
|
|
128
155
|
// "did we come up as what was asked for?" check meaningful instead of
|
|
129
156
|
// special-cased — and pins npx, whose cache dir is per-version.
|
|
130
157
|
const target = restartOnly ? currentVersion : (input?.version ?? '').trim() || 'latest';
|
|
131
|
-
const refusal = !restartOnly &&
|
|
132
|
-
?
|
|
133
|
-
:
|
|
158
|
+
const refusal = !restartOnly && !isSafeVersionSpec(target)
|
|
159
|
+
? `refusing to install "${target}" — a remote roll may name a version or a dist-tag, nothing else`
|
|
160
|
+
: !restartOnly && mode === 'source' && target !== 'latest'
|
|
161
|
+
? 'this node runs from a source checkout — pull and rebuild it there; a remote version bump cannot apply'
|
|
162
|
+
: null;
|
|
134
163
|
return { target, restartOnly, refusal };
|
|
135
164
|
}
|
|
136
165
|
function handoffPath(runtimeDir) {
|
|
@@ -150,22 +179,85 @@ function takeHandoff(runtimeDir) {
|
|
|
150
179
|
}
|
|
151
180
|
catch { /* already gone */ }
|
|
152
181
|
const parsed = JSON.parse(raw);
|
|
153
|
-
|
|
182
|
+
// A handoff with no command_id is the auto-update shape, so the id can no
|
|
183
|
+
// longer be what proves the file is one of ours — from_version is.
|
|
184
|
+
return parsed && typeof parsed.from_version === 'string' ? parsed : null;
|
|
154
185
|
}
|
|
155
186
|
catch {
|
|
156
187
|
return null;
|
|
157
188
|
}
|
|
158
189
|
}
|
|
190
|
+
/** One `npm i -g` attempt. Resolves the failure reason, or null on success. */
|
|
191
|
+
function npmInstallAttempt(spec, prefix, timeoutMs) {
|
|
192
|
+
const args = ['i', '-g', ...(prefix ? ['--prefix', prefix] : []), spec];
|
|
193
|
+
const inv = (0, win_1.resolveCliInvocation)('npm', args);
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
let log = '';
|
|
196
|
+
let settled = false;
|
|
197
|
+
const done = (err) => { if (!settled) {
|
|
198
|
+
settled = true;
|
|
199
|
+
resolve({ err, log });
|
|
200
|
+
} };
|
|
201
|
+
try {
|
|
202
|
+
const child = (0, child_process_1.spawn)(inv.file, inv.args, { windowsHide: true, env: process.env });
|
|
203
|
+
const timer = setTimeout(() => { try {
|
|
204
|
+
child.kill('SIGKILL');
|
|
205
|
+
}
|
|
206
|
+
catch { /* gone */ } done(`npm i -g ${spec} timed out`); }, timeoutMs);
|
|
207
|
+
timer.unref?.();
|
|
208
|
+
child.stdout?.on('data', b => { log += b.toString('utf8'); });
|
|
209
|
+
child.stderr?.on('data', b => { log += b.toString('utf8'); });
|
|
210
|
+
child.on('error', err => { clearTimeout(timer); done(`npm i -g failed to start: ${err.message}`); });
|
|
211
|
+
child.on('exit', code => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
done(code === 0 ? null : `npm i -g ${spec} exited ${code}: ${log.trim().slice(-400)}`);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
done(`npm i -g failed to start: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
159
221
|
/**
|
|
160
|
-
* `npm i -g <
|
|
222
|
+
* `npm i -g <spec>` for any package. Returns null on success, else the reason.
|
|
161
223
|
* Never throws — a failed install must leave the CURRENT daemon running
|
|
162
224
|
* rather than take the node down.
|
|
225
|
+
*
|
|
226
|
+
* On a classic Mac with a root-owned /usr/local prefix the first attempt dies
|
|
227
|
+
* EACCES, so we retry into the daemon's own prefix — the same fallback
|
|
228
|
+
* command-runner's interactive install uses, and the same one the binary
|
|
229
|
+
* finders already search. Without it, auto-update would fail silently forever
|
|
230
|
+
* on exactly the nodes that needed a human least.
|
|
163
231
|
*/
|
|
232
|
+
async function npmInstallGlobal(spec, timeoutMs = 5 * 60_000) {
|
|
233
|
+
const first = await npmInstallAttempt(spec, null, timeoutMs);
|
|
234
|
+
if (!first.err)
|
|
235
|
+
return null;
|
|
236
|
+
if (!/EACCES/.test(first.log))
|
|
237
|
+
return first.err;
|
|
238
|
+
const toolsPrefix = path.join(paths_1.RUNTIME_HOME, 'tools');
|
|
239
|
+
try {
|
|
240
|
+
fs.mkdirSync(toolsPrefix, { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
catch { /* the spawn will surface it */ }
|
|
243
|
+
const retry = await npmInstallAttempt(spec, toolsPrefix, timeoutMs);
|
|
244
|
+
return retry.err;
|
|
245
|
+
}
|
|
246
|
+
/** `npm i -g @addai/node@<version>`. Thin wrapper kept for the roll paths. */
|
|
164
247
|
async function installGlobal(targetVersion, timeoutMs = 5 * 60_000) {
|
|
165
|
-
|
|
166
|
-
|
|
248
|
+
return npmInstallGlobal(`${exports.PACKAGE_NAME}@${targetVersion || 'latest'}`, timeoutMs);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* What npm currently calls `latest` for a package, or null if we couldn't ask.
|
|
252
|
+
*
|
|
253
|
+
* Null is deliberately NOT treated as "up to date" by callers — it means the
|
|
254
|
+
* question went unanswered (offline, registry blip), and an unanswered
|
|
255
|
+
* question must never masquerade as a clean bill of health.
|
|
256
|
+
*/
|
|
257
|
+
function npmLatestVersion(pkg, timeoutMs = 60_000) {
|
|
258
|
+
const inv = (0, win_1.resolveCliInvocation)('npm', ['view', pkg, 'version']);
|
|
167
259
|
return new Promise((resolve) => {
|
|
168
|
-
let
|
|
260
|
+
let out = '';
|
|
169
261
|
let settled = false;
|
|
170
262
|
const done = (v) => { if (!settled) {
|
|
171
263
|
settled = true;
|
|
@@ -176,17 +268,18 @@ async function installGlobal(targetVersion, timeoutMs = 5 * 60_000) {
|
|
|
176
268
|
const timer = setTimeout(() => { try {
|
|
177
269
|
child.kill('SIGKILL');
|
|
178
270
|
}
|
|
179
|
-
catch { /* gone */ } done(
|
|
271
|
+
catch { /* gone */ } done(null); }, timeoutMs);
|
|
180
272
|
timer.unref?.();
|
|
181
|
-
child.
|
|
182
|
-
child.on('error',
|
|
273
|
+
child.stdout?.on('data', b => { out += b.toString('utf8'); });
|
|
274
|
+
child.on('error', () => { clearTimeout(timer); done(null); });
|
|
183
275
|
child.on('exit', code => {
|
|
184
276
|
clearTimeout(timer);
|
|
185
|
-
|
|
277
|
+
const v = out.trim();
|
|
278
|
+
done(code === 0 && /^\d+\.\d+\.\d+/.test(v) ? v : null);
|
|
186
279
|
});
|
|
187
280
|
}
|
|
188
|
-
catch
|
|
189
|
-
done(
|
|
281
|
+
catch {
|
|
282
|
+
done(null);
|
|
190
283
|
}
|
|
191
284
|
});
|
|
192
285
|
}
|
package/dist/store.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { RuntimePairingState } from './types';
|
|
2
2
|
export declare function readPairing(): RuntimePairingState | null;
|
|
3
3
|
export declare function writePairing(state: RuntimePairingState): void;
|
|
4
|
-
export declare function clearPairing(
|
|
4
|
+
export declare function clearPairing(opts?: {
|
|
5
|
+
stateFile?: string;
|
|
6
|
+
legacy?: string;
|
|
7
|
+
}): void;
|
|
5
8
|
export declare function isPaired(): boolean;
|
package/dist/store.js
CHANGED
|
@@ -43,6 +43,7 @@ exports.writePairing = writePairing;
|
|
|
43
43
|
exports.clearPairing = clearPairing;
|
|
44
44
|
exports.isPaired = isPaired;
|
|
45
45
|
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
46
47
|
const paths_1 = require("./paths");
|
|
47
48
|
const win_1 = require("./win");
|
|
48
49
|
function ensureHome() {
|
|
@@ -71,11 +72,30 @@ function writePairing(state) {
|
|
|
71
72
|
// rename-over-open-file fails EPERM/EBUSY on Windows — retried there.
|
|
72
73
|
(0, win_1.safeRenameSync)(tmp, paths_1.RUNTIME_STATE_FILE);
|
|
73
74
|
}
|
|
74
|
-
|
|
75
|
+
// `opts` exists so this is testable without touching the real home — same
|
|
76
|
+
// injection migrateLegacyStateDir takes, for the same reason.
|
|
77
|
+
function clearPairing(opts = {}) {
|
|
78
|
+
const stateFile = opts.stateFile ?? paths_1.RUNTIME_STATE_FILE;
|
|
79
|
+
const legacy = opts.legacy ?? paths_1.LEGACY_RUNTIME_HOME;
|
|
75
80
|
try {
|
|
76
|
-
fs.unlinkSync(
|
|
81
|
+
fs.unlinkSync(stateFile);
|
|
77
82
|
}
|
|
78
83
|
catch { /* already gone */ }
|
|
84
|
+
// The legacy home too. state-migrate COPIES ~/.entities-runtime/state.json
|
|
85
|
+
// into the new home rather than moving it, so the old file outlives the
|
|
86
|
+
// rename on purpose — but nothing ever cleaned it up, and the migration
|
|
87
|
+
// only skips when the new home already has a state.json. Deleting just the
|
|
88
|
+
// new copy therefore didn't unpair anything: the next start copied the old
|
|
89
|
+
// token straight back in, the daemon believed it was already paired (so an
|
|
90
|
+
// explicit `ainode entities <CODE>` never ran the pairing flow at all), the
|
|
91
|
+
// server rejected the dead token, and it cleared state again. That loop
|
|
92
|
+
// survived three releases on a real machine before we found it.
|
|
93
|
+
//
|
|
94
|
+
// Unpairing means unpaired. Both copies go.
|
|
95
|
+
try {
|
|
96
|
+
fs.unlinkSync(path.join(legacy, 'state.json'));
|
|
97
|
+
}
|
|
98
|
+
catch { /* no legacy home, or already gone */ }
|
|
79
99
|
}
|
|
80
100
|
function isPaired() {
|
|
81
101
|
return readPairing() !== null;
|
package/dist/tui/dashboard.js
CHANGED
|
@@ -244,6 +244,14 @@ function createDashboardScreen(deps) {
|
|
|
244
244
|
if (recent.length)
|
|
245
245
|
st.recent = recent;
|
|
246
246
|
st.offline = deps.data.offline();
|
|
247
|
+
// The server can unpair a node out from under a running daemon — the
|
|
248
|
+
// machine removed from the fleet, or its token revoked. The heartbeat
|
|
249
|
+
// notices and clears the local pairing, but this screen was reading
|
|
250
|
+
// `paired` once at startup, so it kept showing the old stats and a header
|
|
251
|
+
// that said Online while every call underneath it came back unauthorized.
|
|
252
|
+
// Re-read it: a node that has lost its pairing should say so and tell you
|
|
253
|
+
// how to come back, not sit there looking healthy and empty.
|
|
254
|
+
st.paired = (0, store_1.isPaired)();
|
|
247
255
|
// A selected run that has finished is no longer in the band. nowIndex
|
|
248
256
|
// already reads that as "cursor is in the menu"; drop the id too so the
|
|
249
257
|
// state doesn't keep pointing at a run nobody can see.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@addai/node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Daemon that pairs a machine with your +Ai account and runs Claude / Codex / Kimi / Gemini agents on its behalf. Reachable via Supabase from Vault, Entity Studio, or any other +Ai surface.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|