hop2 0.11.2 → 0.11.4
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 +7 -1
- package/hay/apps/server/package.json +3 -1
- package/hay/apps/server/src/rooms.ts +142 -33
- package/hay/apps/server/src/screenGrid.ts +145 -0
- package/hay/apps/server/test/rooms.test.ts +104 -0
- package/hay/apps/web/src/App.tsx +70 -105
- package/hay/apps/web/src/components/SessionSwitcher.tsx +126 -15
- package/hay/apps/web/src/styles.css +13 -0
- package/hay/apps/web/src/utils/voiceHold.ts +170 -0
- package/hay/package-lock.json +17 -0
- package/hay/packages/shared/src/protocol.ts +6 -1
- package/hay-web/assets/{index-BohAWpcL.css → index-BRpBf8QE.css} +1 -1
- package/hay-web/assets/index-isX6Rkr2.js +132 -0
- package/hay-web/assets/index-isX6Rkr2.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +539 -150
- package/hopa +6 -1
- package/lib/hay-host-watchdog.js +97 -0
- package/mcp/hop-mcp.js +6 -1
- package/package.json +1 -1
- package/scripts/claude-session-hook.js +24 -3
- package/scripts/hay-host.js +14 -1
- package/scripts/salvage-and-restart-host.js +116 -0
- package/tests/hay-host-watchdog.unit.test.cjs +109 -0
- package/tests/terminal-api.integration.test.cjs +100 -0
- package/hay-web/assets/index-DfGT3DuO.js +0 -130
- package/hay-web/assets/index-DfGT3DuO.js.map +0 -1
package/hopa
CHANGED
|
@@ -286,7 +286,12 @@ async function resolveTerminalRef(server, ref) {
|
|
|
286
286
|
const parsed = JSON.parse((listed.content || []).map((c) => c.text || '').join(''));
|
|
287
287
|
terminals = Array.isArray(parsed.terminals) ? parsed.terminals : [];
|
|
288
288
|
} catch { return ref; }
|
|
289
|
-
const
|
|
289
|
+
const exact = terminals.filter((t) => t && (t.sessionName === ref || t.displayName === ref || t.id === ref));
|
|
290
|
+
// Name refs fold case (unique match only) — same rule as the daemon's lookup.
|
|
291
|
+
const refLower = ref.toLowerCase();
|
|
292
|
+
const foldsTo = (v) => typeof v === 'string' && v.toLowerCase() === refLower;
|
|
293
|
+
const matches = exact.length ? exact
|
|
294
|
+
: terminals.filter((t) => t && (foldsTo(t.sessionName) || foldsTo(t.displayName) || foldsTo(t.id)));
|
|
290
295
|
if (matches.length === 1) return matches[0].id;
|
|
291
296
|
if (matches.length > 1) {
|
|
292
297
|
fail(`"${ref}" matches ${matches.length} terminals: ${matches.map((t) => t.id).join(', ')}`, 2);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
function emptyFailureState() {
|
|
4
|
+
return { pid: 0, count: 0, firstAt: 0 };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function createFailureTracker({ requiredFailures, windowMs, now = Date.now }) {
|
|
8
|
+
if (!Number.isInteger(requiredFailures) || requiredFailures < 1) {
|
|
9
|
+
throw new TypeError('requiredFailures must be a positive integer');
|
|
10
|
+
}
|
|
11
|
+
if (!Number.isFinite(windowMs) || windowMs < 0) {
|
|
12
|
+
throw new TypeError('windowMs must be a non-negative number');
|
|
13
|
+
}
|
|
14
|
+
if (typeof now !== 'function') {
|
|
15
|
+
throw new TypeError('now must be a function');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let state = emptyFailureState();
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
recordFailure(pid) {
|
|
22
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
23
|
+
throw new TypeError('pid must be a positive integer');
|
|
24
|
+
}
|
|
25
|
+
const at = now();
|
|
26
|
+
if (state.pid !== pid) {
|
|
27
|
+
state = { pid, count: 0, firstAt: at };
|
|
28
|
+
}
|
|
29
|
+
state.count += 1;
|
|
30
|
+
const failingForMs = Math.max(0, at - state.firstAt);
|
|
31
|
+
return {
|
|
32
|
+
...state,
|
|
33
|
+
failingForMs,
|
|
34
|
+
shouldRestart: state.count >= requiredFailures && failingForMs >= windowMs
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
recordSuccess(pid) {
|
|
39
|
+
const recovered = state.pid === pid && state.count > 0
|
|
40
|
+
? { ...state, failingForMs: Math.max(0, now() - state.firstAt) }
|
|
41
|
+
: null;
|
|
42
|
+
state = emptyFailureState();
|
|
43
|
+
return recovered;
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
reset() {
|
|
47
|
+
state = emptyFailureState();
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
snapshot() {
|
|
51
|
+
return { ...state };
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createSingleFlight() {
|
|
57
|
+
let pending = null;
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
run(work) {
|
|
61
|
+
if (pending) return pending;
|
|
62
|
+
if (typeof work !== 'function') {
|
|
63
|
+
throw new TypeError('work must be a function');
|
|
64
|
+
}
|
|
65
|
+
const current = Promise.resolve().then(work);
|
|
66
|
+
pending = current;
|
|
67
|
+
const clear = () => {
|
|
68
|
+
if (pending === current) pending = null;
|
|
69
|
+
};
|
|
70
|
+
current.then(clear, clear);
|
|
71
|
+
return current;
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
isRunning() {
|
|
75
|
+
return pending !== null;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sameHostIncarnation(a, b) {
|
|
81
|
+
if (!a || !b) return false;
|
|
82
|
+
if (Number(a.pid) !== Number(b.pid) || Number(a.port) !== Number(b.port)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
const aStartedAt = Number(a.startedAt);
|
|
86
|
+
const bStartedAt = Number(b.startedAt);
|
|
87
|
+
if (aStartedAt > 0 && bStartedAt > 0) {
|
|
88
|
+
return aStartedAt === bStartedAt;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
createFailureTracker,
|
|
95
|
+
createSingleFlight,
|
|
96
|
+
sameHostIncarnation
|
|
97
|
+
};
|
package/mcp/hop-mcp.js
CHANGED
|
@@ -4901,7 +4901,12 @@ class HopMCPServer {
|
|
|
4901
4901
|
try {
|
|
4902
4902
|
const listed = await this.callApi('GET', '/api/sessions');
|
|
4903
4903
|
if (listed && Array.isArray(listed.sessions)) {
|
|
4904
|
-
const
|
|
4904
|
+
const nameLower = name.toLowerCase();
|
|
4905
|
+
const foldsTo = (v) => typeof v === 'string' && v.toLowerCase() === nameLower;
|
|
4906
|
+
const exact = listed.sessions.find((s) => s && (s.name === name || s.displayName === name || s.internalName === name));
|
|
4907
|
+
// Case-insensitive fallback (unique match only) — same rule as the daemon.
|
|
4908
|
+
const folded = exact ? [] : listed.sessions.filter((s) => s && (foldsTo(s.name) || foldsTo(s.displayName) || foldsTo(s.internalName)));
|
|
4909
|
+
const match = exact || (folded.length === 1 ? folded[0] : null);
|
|
4905
4910
|
if (match) {
|
|
4906
4911
|
matched = true;
|
|
4907
4912
|
internalName = match.internalName || match.name || name;
|
package/package.json
CHANGED
|
@@ -18,6 +18,27 @@
|
|
|
18
18
|
|
|
19
19
|
const fs = require("node:fs");
|
|
20
20
|
const os = require("node:os");
|
|
21
|
+
|
|
22
|
+
// Crash-safe write: temp + fsync + atomic rename. Records are the restore
|
|
23
|
+
// inventory — an un-fsynced page cache at power loss must not be able to
|
|
24
|
+
// take a conversation's identity with it (the 07-29 thermal-sleep crash
|
|
25
|
+
// erased three days of plainly-written files while fsynced ones survived).
|
|
26
|
+
function writeDurable(file, data) {
|
|
27
|
+
try {
|
|
28
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
29
|
+
const fd = fs.openSync(tmp, "w", 0o600);
|
|
30
|
+
try {
|
|
31
|
+
fs.writeSync(fd, data);
|
|
32
|
+
fs.fsyncSync(fd);
|
|
33
|
+
} finally {
|
|
34
|
+
fs.closeSync(fd);
|
|
35
|
+
}
|
|
36
|
+
fs.renameSync(tmp, file);
|
|
37
|
+
} catch {
|
|
38
|
+
try { fs.writeFileSync(file, data, { mode: 0o600 }); } catch { /* never disrupt claude */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
const path = require("node:path");
|
|
22
43
|
const { execFileSync } = require("node:child_process");
|
|
23
44
|
|
|
@@ -86,13 +107,13 @@ function recordSession(dir, hopSession, payload) {
|
|
|
86
107
|
const prev = JSON.parse(prevRaw);
|
|
87
108
|
if (prev && prev.sessionId && prev.sessionId !== sessionId && prev.cwd && prev.cwd !== cwd) {
|
|
88
109
|
try {
|
|
89
|
-
|
|
110
|
+
writeDurable(path.join(dir, `${hopSession}.other.json`), record);
|
|
90
111
|
} catch { /* ignore */ }
|
|
91
112
|
return;
|
|
92
113
|
}
|
|
93
114
|
} catch { /* no incumbent (or unreadable) — record normally */ }
|
|
94
115
|
try {
|
|
95
|
-
|
|
116
|
+
writeDurable(file, record);
|
|
96
117
|
} catch { /* ignore */ }
|
|
97
118
|
}
|
|
98
119
|
|
|
@@ -109,7 +130,7 @@ function bumpTurn(dir, hopSession, payload) {
|
|
|
109
130
|
at: new Date().toISOString()
|
|
110
131
|
});
|
|
111
132
|
try {
|
|
112
|
-
|
|
133
|
+
writeDurable(file, record);
|
|
113
134
|
} catch { /* ignore */ }
|
|
114
135
|
}
|
|
115
136
|
|
package/scripts/hay-host.js
CHANGED
|
@@ -370,7 +370,9 @@ async function main() {
|
|
|
370
370
|
cols,
|
|
371
371
|
rows,
|
|
372
372
|
// Per-connection snapshot cap (monitor tiles ask small).
|
|
373
|
-
|
|
373
|
+
// 0 passes through as an explicit "no snapshot" — dropping it
|
|
374
|
+
// to undefined made claim sockets pull the full default tail.
|
|
375
|
+
replayBytes: Number.isFinite(replayRaw) && replayRaw >= 0 ? replayRaw : undefined,
|
|
374
376
|
// nudge=0: viewer already shows the current grid (wall tile)
|
|
375
377
|
nudge: wsUrl.searchParams.get('nudge') === '0' ? false : undefined
|
|
376
378
|
},
|
|
@@ -378,6 +380,17 @@ async function main() {
|
|
|
378
380
|
);
|
|
379
381
|
});
|
|
380
382
|
|
|
383
|
+
// Periodic notekeeping: the shutdown handler only runs on a GRACEFUL
|
|
384
|
+
// exit, so a crash (thermal sleep, power cut, SIGKILL) used to leave no
|
|
385
|
+
// buffers at all — `hop restore` had screens to replay only when the
|
|
386
|
+
// stop was polite. Flush the same bounded tails every few minutes; a
|
|
387
|
+
// crash now costs at most the last interval of scrollback.
|
|
388
|
+
const bufferFlushTimer = setInterval(() => {
|
|
389
|
+
if (shuttingDown) return;
|
|
390
|
+
try { persistRoomBuffers(rooms); } catch (e) { /* next tick */ }
|
|
391
|
+
}, 4 * 60 * 1000);
|
|
392
|
+
bufferFlushTimer.unref?.();
|
|
393
|
+
|
|
381
394
|
const shutdown = () => {
|
|
382
395
|
shuttingDown = true; // keep restore records for rooms killed by this shutdown
|
|
383
396
|
// Persist tail buffers BEFORE closeAll() kills the PTYs, so a graceful
|
|
@@ -68,6 +68,50 @@ const salvageRoom = (port, id) => new Promise((resolve) => {
|
|
|
68
68
|
|
|
69
69
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
70
70
|
|
|
71
|
+
// Daemon API (Bearer-token) — used for the pre/post state census: room IDs
|
|
72
|
+
// alone can't tell whether RENAMES and FOLDERS survived, and "restored" must
|
|
73
|
+
// mean the user-visible state came back, not merely that a PTY exists.
|
|
74
|
+
const daemonApi = (method, p, body) => new Promise((resolve) => {
|
|
75
|
+
const ds = daemonState();
|
|
76
|
+
const data = body ? JSON.stringify(body) : null;
|
|
77
|
+
const req = http.request({
|
|
78
|
+
host: '127.0.0.1', port: ds.port, path: p, method,
|
|
79
|
+
headers: { Authorization: 'Bearer ' + ds.sessionSecret, ...(data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}) }
|
|
80
|
+
}, (res) => { let d = ''; res.on('data', (c) => d += c); res.on('end', () => resolve({ status: res.statusCode, data: d })); });
|
|
81
|
+
req.on('error', () => resolve({ status: 0, data: '' }));
|
|
82
|
+
// destroy() WITH an error: a bare destroy() emits no 'error' event, the
|
|
83
|
+
// promise never settles, and the whole run hangs silently on one slow
|
|
84
|
+
// request (exactly what stranded the first verified restart at RECONCILE).
|
|
85
|
+
req.setTimeout(20000, () => req.destroy(new Error('timeout')));
|
|
86
|
+
if (data) req.end(data); else req.end();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const sessionCensus = async () => {
|
|
90
|
+
const res = await daemonApi('GET', '/api/sessions');
|
|
91
|
+
try {
|
|
92
|
+
const sessions = JSON.parse(res.data).sessions || [];
|
|
93
|
+
const map = {};
|
|
94
|
+
for (const s of sessions) {
|
|
95
|
+
if (!s || s.type === 'port') continue;
|
|
96
|
+
map[s.internalName || s.name] = {
|
|
97
|
+
displayName: s.displayName || s.name,
|
|
98
|
+
folderId: s.folderId || null,
|
|
99
|
+
live: !!s.live
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return map;
|
|
103
|
+
} catch (e) { return null; }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Same screen-witness the restore machinery trusts: claude's own chrome.
|
|
107
|
+
const CLAUDE_UI_RE = /bypass permissions on|shift\+tab to cycle|Welcome back|esc to interrupt|\? for shortcuts/i;
|
|
108
|
+
const RESUME_PROMPT_RE = /Resuming the full session|Resume from summary|Resume full session as-is/i;
|
|
109
|
+
|
|
110
|
+
const roomPreviewText = async (id) => {
|
|
111
|
+
const res = await daemonApi('GET', `/api/sessions/preview?name=${encodeURIComponent(id)}`);
|
|
112
|
+
try { return String(JSON.parse(res.data).text || ''); } catch (e) { return ''; }
|
|
113
|
+
};
|
|
114
|
+
|
|
71
115
|
// Same lifecycle lock the hop CLI takes (~/.hop2/.lifecycle.lock): this run
|
|
72
116
|
// cycles the host, and a `hop stop`/`start`/`restore` landing in the middle
|
|
73
117
|
// of it kills sessions mid-relaunch. Stale holders (dead pid, absurdly old)
|
|
@@ -128,6 +172,12 @@ const releaseLock = () => {
|
|
|
128
172
|
const preRooms = (await hostGet('/rooms')).rooms;
|
|
129
173
|
const rooms = preRooms.map((r) => r.id);
|
|
130
174
|
fs.writeFileSync(path.join(OUT, 'pre-rooms.json'), JSON.stringify(preRooms, null, 1), { mode: 0o600 });
|
|
175
|
+
// Daemon-side census too: display names and folder membership are the
|
|
176
|
+
// state the user actually sees, and "back to the same state" is judged
|
|
177
|
+
// against THIS, not against bare room ids.
|
|
178
|
+
const preSessions = await sessionCensus();
|
|
179
|
+
if (preSessions) fs.writeFileSync(path.join(OUT, 'pre-sessions.json'), JSON.stringify(preSessions, null, 1), { mode: 0o600 });
|
|
180
|
+
else log('WARN: pre-restart daemon census unavailable — name/folder verification will be skipped');
|
|
131
181
|
log(`salvaging ${rooms.length} rooms -> ${OUT}`);
|
|
132
182
|
const salvageStats = {};
|
|
133
183
|
for (const id of rooms) {
|
|
@@ -173,6 +223,72 @@ const releaseLock = () => {
|
|
|
173
223
|
fs.writeFileSync(path.join(OUT, 'reconcile.json'),
|
|
174
224
|
JSON.stringify({ preCount: rooms.length, restored, missing, extra, salvageMisses }, null, 1), { mode: 0o600 });
|
|
175
225
|
} catch (e) { log('reconcile err: ' + e.message); }
|
|
226
|
+
|
|
227
|
+
// 4b. STATE verification: names, folders, and claude actually resumed.
|
|
228
|
+
// A room can exist while wearing the wrong name, sitting in no folder,
|
|
229
|
+
// or stuck at claude's resume question — each of those has happened, and
|
|
230
|
+
// each reads as "restore worked" to a bare room-id reconcile.
|
|
231
|
+
try {
|
|
232
|
+
const preSessions = JSON.parse(fs.readFileSync(path.join(OUT, 'pre-sessions.json'), 'utf8'));
|
|
233
|
+
const claudeRooms = rooms.filter((id) =>
|
|
234
|
+
fs.existsSync(path.join(HOME, 'claude-sessions', `${id}.json`)));
|
|
235
|
+
const problems = [];
|
|
236
|
+
const post = await sessionCensus() || {};
|
|
237
|
+
for (const [id, pre] of Object.entries(preSessions)) {
|
|
238
|
+
if (!pre.live) continue; // dead-at-shutdown sessions owe nothing
|
|
239
|
+
const cur = post[id];
|
|
240
|
+
if (!cur || !cur.live) { problems.push(`${id}: not live after restore`); continue; }
|
|
241
|
+
if (cur.displayName !== pre.displayName) problems.push(`${id}: name "${pre.displayName}" → "${cur.displayName}"`);
|
|
242
|
+
if ((cur.folderId || null) !== (pre.folderId || null)) problems.push(`${id}: folder ${pre.folderId || '(none)'} → ${cur.folderId || '(none)'}`);
|
|
243
|
+
}
|
|
244
|
+
// Claude resume witness: poll each claude room's screen for claude's
|
|
245
|
+
// own chrome; a lingering resume question gets answered by the same
|
|
246
|
+
// command a human would use, then re-checked.
|
|
247
|
+
let pendingClaude = new Set(claudeRooms);
|
|
248
|
+
let resumeNudged = false;
|
|
249
|
+
for (let i = 0; i < 18 && pendingClaude.size > 0; i++) {
|
|
250
|
+
for (const id of [...pendingClaude]) {
|
|
251
|
+
const text = await roomPreviewText(id);
|
|
252
|
+
if (CLAUDE_UI_RE.test(text) && !RESUME_PROMPT_RE.test(text)) { pendingClaude.delete(id); continue; }
|
|
253
|
+
if (RESUME_PROMPT_RE.test(text) && !resumeNudged) {
|
|
254
|
+
resumeNudged = true;
|
|
255
|
+
log('resume question on screen — running hop resume-waiting');
|
|
256
|
+
try { log(execSync('hop resume-waiting 2>&1 | tail -5', { encoding: 'utf8', timeout: 90000 })); } catch (e) { log('resume-waiting err: ' + e.message); }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (pendingClaude.size > 0) await sleep(5000);
|
|
260
|
+
}
|
|
261
|
+
for (const id of pendingClaude) problems.push(`${id}: claude chrome never appeared (still starting, or resume failed)`);
|
|
262
|
+
log(`VERIFY: ${Object.keys(preSessions).filter((k) => preSessions[k].live).length} live sessions checked, ` +
|
|
263
|
+
`${claudeRooms.length} claude rooms witnessed, ${problems.length} problem(s)`);
|
|
264
|
+
for (const p of problems) log(`VERIFY FAIL: ${p}`);
|
|
265
|
+
if (problems.length === 0) log('VERIFY: state matches the pre-restart census — names, folders, claude all back ✓');
|
|
266
|
+
fs.writeFileSync(path.join(OUT, 'verification.json'), JSON.stringify({ problems, claudeRooms }, null, 1), { mode: 0o600 });
|
|
267
|
+
} catch (e) { log('verify err: ' + (e && e.stack || e)); }
|
|
268
|
+
|
|
269
|
+
// 4c. Serialized-attach probe: the whole point of this cycle is the new
|
|
270
|
+
// host code — witness a serialized snapshot (in-band RIS prefix) on a
|
|
271
|
+
// bounded-replay attach, exactly as a wall tile would connect.
|
|
272
|
+
try {
|
|
273
|
+
const probeRoom = rooms.find((id) => id !== 'Solstice') || rooms[0];
|
|
274
|
+
const nh = hostState();
|
|
275
|
+
const result = await new Promise((resolve) => {
|
|
276
|
+
const ws = new WebSocket(`ws://127.0.0.1:${nh.port}/ws?room=${encodeURIComponent(probeRoom)}&name=verify&cols=100&rows=30&replay=65536&nudge=0`);
|
|
277
|
+
const timer = setTimeout(() => { try { ws.close(); } catch (e) {} resolve('timeout'); }, 15000);
|
|
278
|
+
ws.on('message', (data) => {
|
|
279
|
+
try {
|
|
280
|
+
const msg = JSON.parse(String(data));
|
|
281
|
+
if (msg.type === 'snapshot') {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
try { ws.close(); } catch (e) {}
|
|
284
|
+
resolve(msg.data.startsWith('\x1bc') ? `serialized (${msg.data.length}B, capped=${msg.capped === true})` : `raw (${msg.data.length}B)`);
|
|
285
|
+
}
|
|
286
|
+
} catch (e) { /* keep waiting */ }
|
|
287
|
+
});
|
|
288
|
+
ws.on('error', () => { clearTimeout(timer); resolve('ws-error'); });
|
|
289
|
+
});
|
|
290
|
+
log(`serialized attach probe on ${probeRoom}: ${result}${String(result).startsWith('serialized') ? ' ✓ ACTIVE' : ' — NOT active'}`);
|
|
291
|
+
} catch (e) { log('serialized probe err: ' + e.message); }
|
|
176
292
|
const ds = daemonState();
|
|
177
293
|
const api = (method, p, body) => new Promise((resolve) => {
|
|
178
294
|
const data = body ? JSON.stringify(body) : null;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const test = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const {
|
|
4
|
+
createFailureTracker,
|
|
5
|
+
createSingleFlight,
|
|
6
|
+
sameHostIncarnation
|
|
7
|
+
} = require('../lib/hay-host-watchdog');
|
|
8
|
+
|
|
9
|
+
test('a successful probe resets the complete failure streak', () => {
|
|
10
|
+
let now = 0;
|
|
11
|
+
const tracker = createFailureTracker({
|
|
12
|
+
requiredFailures: 3,
|
|
13
|
+
windowMs: 60_000,
|
|
14
|
+
now: () => now
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
assert.equal(tracker.recordFailure(42).shouldRestart, false);
|
|
18
|
+
now = 17_000;
|
|
19
|
+
assert.equal(tracker.recordFailure(42).shouldRestart, false);
|
|
20
|
+
|
|
21
|
+
now = 53_000;
|
|
22
|
+
const recovered = tracker.recordSuccess(42);
|
|
23
|
+
assert.equal(recovered.count, 2);
|
|
24
|
+
assert.deepEqual(tracker.snapshot(), { pid: 0, count: 0, firstAt: 0 });
|
|
25
|
+
|
|
26
|
+
now = 62_000;
|
|
27
|
+
const nextFailure = tracker.recordFailure(42);
|
|
28
|
+
assert.equal(nextFailure.count, 1);
|
|
29
|
+
assert.equal(nextFailure.failingForMs, 0);
|
|
30
|
+
assert.equal(nextFailure.shouldRestart, false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('restart requires enough consecutive failures spanning the full window', () => {
|
|
34
|
+
let now = 0;
|
|
35
|
+
const tracker = createFailureTracker({
|
|
36
|
+
requiredFailures: 3,
|
|
37
|
+
windowMs: 60_000,
|
|
38
|
+
now: () => now
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
tracker.recordFailure(7);
|
|
42
|
+
now = 20_000;
|
|
43
|
+
tracker.recordFailure(7);
|
|
44
|
+
now = 59_999;
|
|
45
|
+
assert.equal(tracker.recordFailure(7).shouldRestart, false);
|
|
46
|
+
now = 60_000;
|
|
47
|
+
assert.equal(tracker.recordFailure(7).shouldRestart, true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('failure history never crosses host incarnations', () => {
|
|
51
|
+
let now = 0;
|
|
52
|
+
const tracker = createFailureTracker({
|
|
53
|
+
requiredFailures: 3,
|
|
54
|
+
windowMs: 60_000,
|
|
55
|
+
now: () => now
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
tracker.recordFailure(7);
|
|
59
|
+
now = 61_000;
|
|
60
|
+
const firstFailureForReplacement = tracker.recordFailure(8);
|
|
61
|
+
assert.equal(firstFailureForReplacement.count, 1);
|
|
62
|
+
assert.equal(firstFailureForReplacement.failingForMs, 0);
|
|
63
|
+
assert.equal(firstFailureForReplacement.shouldRestart, false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('single flight shares one operation across concurrent callers and clears afterward', async () => {
|
|
67
|
+
const flight = createSingleFlight();
|
|
68
|
+
let starts = 0;
|
|
69
|
+
let release;
|
|
70
|
+
const blocked = new Promise((resolve) => { release = resolve; });
|
|
71
|
+
const work = async () => {
|
|
72
|
+
starts += 1;
|
|
73
|
+
await blocked;
|
|
74
|
+
return 'ready';
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const callers = Array.from({ length: 15 }, () => flight.run(work));
|
|
78
|
+
assert.equal(starts, 0, 'work starts on the next microtask');
|
|
79
|
+
await Promise.resolve();
|
|
80
|
+
assert.equal(starts, 1);
|
|
81
|
+
assert.equal(new Set(callers).size, 1, 'all callers receive the same promise');
|
|
82
|
+
|
|
83
|
+
release();
|
|
84
|
+
assert.deepEqual(await Promise.all(callers), Array(15).fill('ready'));
|
|
85
|
+
assert.equal(flight.isRunning(), false);
|
|
86
|
+
|
|
87
|
+
assert.equal(await flight.run(async () => {
|
|
88
|
+
starts += 1;
|
|
89
|
+
return 'again';
|
|
90
|
+
}), 'again');
|
|
91
|
+
assert.equal(starts, 2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('single flight clears after a rejected operation so recovery can retry', async () => {
|
|
95
|
+
const flight = createSingleFlight();
|
|
96
|
+
await assert.rejects(flight.run(async () => {
|
|
97
|
+
throw new Error('probe failed');
|
|
98
|
+
}), /probe failed/);
|
|
99
|
+
assert.equal(flight.isRunning(), false);
|
|
100
|
+
assert.equal(await flight.run(async () => 'recovered'), 'recovered');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('host incarnation comparison includes pid, port, and start time', () => {
|
|
104
|
+
const host = { pid: 7, port: 5000, startedAt: 100 };
|
|
105
|
+
assert.equal(sameHostIncarnation(host, { ...host }), true);
|
|
106
|
+
assert.equal(sameHostIncarnation(host, { ...host, pid: 8 }), false);
|
|
107
|
+
assert.equal(sameHostIncarnation(host, { ...host, port: 5001 }), false);
|
|
108
|
+
assert.equal(sameHostIncarnation(host, { ...host, startedAt: 101 }), false);
|
|
109
|
+
});
|
|
@@ -584,3 +584,103 @@ test('a display name freed by a later rename can be reused', async () => {
|
|
|
584
584
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Final', internalName: 'AliasOne' });
|
|
585
585
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'Freed', internalName: 'AliasTwo' });
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
// Regression: folder membership must survive every cache rebuild. The store
|
|
589
|
+
// entry is a CACHE any path may recreate without folderId (restore's fresh
|
|
590
|
+
// session definitions did exactly that), and buildSessionEntries' store loop
|
|
591
|
+
// read that raw cache — clobbering the correct value the durable meta (and
|
|
592
|
+
// the live-rooms loop) already knew. Nine of eleven folder assignments
|
|
593
|
+
// vanished from the UI after a host cycle while every meta on disk was right.
|
|
594
|
+
test('folder membership survives restore re-registration and a daemon restart', async () => {
|
|
595
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
596
|
+
{ name: 'FolderKeeper', type: 'terminal', cwd: tempDir, startRuntime: true });
|
|
597
|
+
await delay(600);
|
|
598
|
+
const folder = await requestJson(state.port, state.sessionSecret, 'POST', '/api/folders', { name: 'KeeperShelf' });
|
|
599
|
+
assert.equal(folder.status, 200, `folder create failed: ${JSON.stringify(folder.data)}`);
|
|
600
|
+
const folderId = folder.data.folder.id;
|
|
601
|
+
const moved = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/move',
|
|
602
|
+
{ name: 'FolderKeeper', folderId });
|
|
603
|
+
assert.equal(moved.status, 200, `move failed: ${JSON.stringify(moved.data)}`);
|
|
604
|
+
|
|
605
|
+
const listedNow = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
606
|
+
assert.equal(listedNow.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
|
|
607
|
+
'freshly moved session must list its folder');
|
|
608
|
+
|
|
609
|
+
// Restore re-registration recreates cached session definitions — the write
|
|
610
|
+
// that used to outrank the durable meta in the listing.
|
|
611
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/restore', {});
|
|
612
|
+
await delay(1200);
|
|
613
|
+
const afterRestore = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
614
|
+
assert.equal(afterRestore.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
|
|
615
|
+
'folder membership must survive restore re-registration');
|
|
616
|
+
|
|
617
|
+
const previousState = state;
|
|
618
|
+
await stopDaemon(true);
|
|
619
|
+
await launchDaemon(previousState);
|
|
620
|
+
const afterRestart = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
621
|
+
assert.equal(afterRestart.data.sessions.find(s => s.internalName === 'FolderKeeper')?.folderId, folderId,
|
|
622
|
+
'folder membership must survive a daemon restart');
|
|
623
|
+
|
|
624
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'FolderKeeper', internalName: 'FolderKeeper' });
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// Session names resolve case-insensitively everywhere a name addresses a
|
|
628
|
+
// session: exact-case wearers win, a UNIQUE folded match resolves, and two
|
|
629
|
+
// sessions differing only by case stay exact-only rather than guessed at.
|
|
630
|
+
test('session names resolve case-insensitively', async () => {
|
|
631
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
632
|
+
{ name: 'CaseFold', type: 'terminal', cwd: tempDir, startRuntime: true });
|
|
633
|
+
await delay(600);
|
|
634
|
+
|
|
635
|
+
// A by-name API call accepts any casing — no internalName hint given.
|
|
636
|
+
const renamed = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
|
|
637
|
+
{ oldName: 'casefold', newName: 'CaseKept' });
|
|
638
|
+
assert.equal(renamed.status, 200, `lowercase lookup must find CaseFold: ${JSON.stringify(renamed.data)}`);
|
|
639
|
+
|
|
640
|
+
// Changing ONLY the casing of your own name is not a collision.
|
|
641
|
+
const caseOnly = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
|
|
642
|
+
{ oldName: 'CaseKept', newName: 'casekept' });
|
|
643
|
+
assert.equal(caseOnly.status, 200, `case-only rename must be allowed: ${JSON.stringify(caseOnly.data)}`);
|
|
644
|
+
|
|
645
|
+
const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
646
|
+
const worn = list.data.sessions.filter(s => s.internalName === 'CaseFold');
|
|
647
|
+
assert.equal(worn.length, 1, 'still exactly one session — folded lookups must never mint twins');
|
|
648
|
+
assert.equal(worn[0].displayName, 'casekept', 'the case-only rename is the worn spelling');
|
|
649
|
+
|
|
650
|
+
// A name another session wears — in ANY casing — is a collision.
|
|
651
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
652
|
+
{ name: 'CaseOther', type: 'terminal', cwd: tempDir, startRuntime: true });
|
|
653
|
+
await delay(400);
|
|
654
|
+
const collide = await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/rename',
|
|
655
|
+
{ oldName: 'CaseOther', newName: 'CASEKEPT', internalName: 'CaseOther' });
|
|
656
|
+
assert.equal(collide.status, 409, 'a differently-cased spelling of a worn name must be refused');
|
|
657
|
+
|
|
658
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'casekept', internalName: 'CaseFold' });
|
|
659
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CaseOther', internalName: 'CaseOther' });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// Regression: an undeclared Bearer-token creation is automation, not a human.
|
|
663
|
+
// Agents' raw scripts rarely remember x-hop-actor, and every one that forgot
|
|
664
|
+
// landed its sessions in the USER tab (SelectProbe). Cookie-auth (the web UI)
|
|
665
|
+
// and the hop CLI's via stamp still default to user; explicit headers win.
|
|
666
|
+
test('bare token API creations default to agent origin', async () => {
|
|
667
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
668
|
+
{ name: 'BareTokenProbe', type: 'terminal', cwd: tempDir, startRuntime: true });
|
|
669
|
+
await delay(400);
|
|
670
|
+
const list = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
671
|
+
const s = list.data.sessions.find(x => x.internalName === 'BareTokenProbe');
|
|
672
|
+
assert.ok(s, 'session exists');
|
|
673
|
+
assert.equal(s.createdBy, 'agent', `undeclared token caller must be agent, got ${s.createdBy}`);
|
|
674
|
+
|
|
675
|
+
// The hop CLI's own via stamp keeps humans human.
|
|
676
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions',
|
|
677
|
+
{ name: 'CliUserProbe', type: 'terminal', cwd: tempDir, startRuntime: true },
|
|
678
|
+
{ 'X-Hop-Via': 'cli' });
|
|
679
|
+
await delay(400);
|
|
680
|
+
const list2 = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
681
|
+
const s2 = list2.data.sessions.find(x => x.internalName === 'CliUserProbe');
|
|
682
|
+
assert.equal(s2 && s2.createdBy, 'user', 'CLI-stamped caller stays user');
|
|
683
|
+
|
|
684
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
|
|
685
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
|
|
686
|
+
});
|