@logicseek/hello-cc 0.1.5 → 0.1.6
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/CHANGELOG.md +75 -0
- package/bin/hcc.mjs +1541 -3430
- package/docs/README.md +2 -0
- package/docs/README.zh-CN.md +3 -1
- package/docs/architecture-split-review.md +217 -0
- package/docs/architecture.md +385 -0
- package/docs/architecture.zh-CN.md +364 -0
- package/docs/layout-split-stack-audit.md +298 -0
- package/lib/automation.mjs +8 -0
- package/lib/cli-args.mjs +1 -1
- package/lib/cli-runtime.mjs +44 -0
- package/lib/coordination-state.mjs +218 -0
- package/lib/core/coordination/automation.mjs +222 -0
- package/lib/core/coordination/locks.mjs +46 -0
- package/lib/core/coordination/messages.mjs +116 -0
- package/lib/core/coordination/tasks.mjs +224 -0
- package/lib/core/coordination/teams.mjs +73 -0
- package/lib/core/coordination/timeline.mjs +187 -0
- package/lib/core/peers/bindings.mjs +83 -0
- package/lib/core/peers/format.mjs +13 -0
- package/lib/core/peers/identity.mjs +53 -0
- package/lib/core/peers/liveness.mjs +90 -0
- package/lib/core/peers/reconcile.mjs +238 -0
- package/lib/core/peers/session.mjs +14 -0
- package/lib/core/sessions/launch.mjs +89 -0
- package/lib/db/schema.mjs +324 -0
- package/lib/db/stores/peers.mjs +231 -0
- package/lib/db-schema.mjs +7 -324
- package/lib/errors.mjs +1 -7
- package/lib/handoff.mjs +26 -0
- package/lib/help.mjs +3 -0
- package/lib/integrations/hooks.mjs +171 -0
- package/lib/integrations/peers/identity.mjs +208 -0
- package/lib/integrations/peers/processes.mjs +151 -0
- package/lib/integrations/providers.mjs +282 -0
- package/lib/integrations/shims/script.mjs +384 -0
- package/lib/integrations/shims.mjs +106 -0
- package/lib/json-file.mjs +1 -14
- package/lib/locks.mjs +10 -0
- package/lib/messages.mjs +3 -0
- package/lib/package-meta.mjs +6 -24
- package/lib/peer-bindings.mjs +14 -0
- package/lib/peer-format.mjs +4 -0
- package/lib/peer-identity.mjs +15 -0
- package/lib/project-context.mjs +6 -0
- package/lib/project-registry.mjs +7 -0
- package/lib/provider-commands.mjs +13 -0
- package/lib/release/package-meta.mjs +31 -0
- package/lib/release/release-notes.mjs +40 -0
- package/lib/release-notes.mjs +6 -40
- package/lib/runtime/client.mjs +42 -0
- package/lib/runtime/paths.mjs +45 -0
- package/lib/runtime/project-context.mjs +32 -0
- package/lib/runtime/projects.mjs +76 -0
- package/lib/runtime/state.mjs +128 -0
- package/lib/runtime-client.mjs +3 -0
- package/lib/runtime-paths.mjs +11 -45
- package/lib/runtime-state.mjs +11 -0
- package/lib/session-launch.mjs +17 -0
- package/lib/setup.mjs +17 -293
- package/lib/shared/errors.mjs +7 -0
- package/lib/shared/json-file.mjs +14 -0
- package/lib/shim-script.mjs +1 -384
- package/lib/state-render.mjs +5 -0
- package/lib/task-cli.mjs +45 -0
- package/lib/task-liveness.mjs +8 -0
- package/lib/task-store.mjs +3 -0
- package/lib/team-planning.mjs +7 -0
- package/lib/terminal/tmux.mjs +372 -0
- package/lib/timeline.mjs +12 -0
- package/lib/tmux.mjs +18 -0
- package/lib/ui/help.mjs +419 -0
- package/lib/ui/state-render.mjs +61 -0
- package/lib/web/http.mjs +55 -0
- package/lib/web/peer-actions.mjs +270 -0
- package/lib/web/runtime.mjs +154 -0
- package/lib/web/ui-template.mjs +2055 -0
- package/lib/web-http.mjs +7 -55
- package/lib/web-peer-actions.mjs +3 -0
- package/lib/web-runtime.mjs +17 -108
- package/lib/web-ui-template.mjs +3 -1909
- package/package.json +1 -1
- package/scripts/github-release.mjs +2 -2
- package/scripts/regression.mjs +2468 -17
- package/scripts/release-notes.mjs +2 -2
package/scripts/regression.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
|
+
import http from 'node:http';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import path from 'node:path';
|
|
6
7
|
import process from 'node:process';
|
|
7
8
|
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import crypto from 'node:crypto';
|
|
8
10
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
9
11
|
import { DatabaseSync } from 'node:sqlite';
|
|
10
12
|
import { Readable } from 'node:stream';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
11
14
|
import WebSocket from 'ws';
|
|
12
15
|
|
|
13
16
|
const repoRoot = path.resolve(import.meta.dirname, '..');
|
|
@@ -18,6 +21,11 @@ const home = path.join(os.tmpdir(), `hcc-reg-home-${testId}`);
|
|
|
18
21
|
const fakeBin = path.join(os.tmpdir(), `hcc-reg-bin-${testId}`);
|
|
19
22
|
const outDir = path.join(os.tmpdir(), `hcc-reg-out-${testId}`);
|
|
20
23
|
const tmuxSession = `hcc-reg-${process.pid}`;
|
|
24
|
+
const tmuxSocketName = `hcc-reg-${testId}`.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
25
|
+
const realTmuxBin = spawnSync('sh', ['-lc', 'command -v tmux || true'], {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
28
|
+
}).stdout.trim();
|
|
21
29
|
const port = 22000 + (process.pid % 10000);
|
|
22
30
|
|
|
23
31
|
let tmuxStarted = false;
|
|
@@ -32,6 +40,8 @@ const env = {
|
|
|
32
40
|
for (const key of Object.keys(env)) {
|
|
33
41
|
if (key.startsWith('HCC_')) delete env[key];
|
|
34
42
|
}
|
|
43
|
+
delete env.TMUX;
|
|
44
|
+
delete env.TMUX_PANE;
|
|
35
45
|
|
|
36
46
|
function log(message) {
|
|
37
47
|
process.stdout.write(`${message}\n`);
|
|
@@ -49,6 +59,19 @@ function sh(value) {
|
|
|
49
59
|
return `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
function shortHash(value) {
|
|
63
|
+
return crypto.createHash('sha1').update(String(value)).digest('hex').slice(0, 8);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sanitizePeerPart(value, fallback = 'peer') {
|
|
67
|
+
const cleaned = String(value || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
68
|
+
return cleaned || fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tmuxManagedSession(projectRoot, peer) {
|
|
72
|
+
return `hcc-${shortHash(projectRoot)}-${sanitizePeerPart(peer, 'peer')}`.slice(0, 80);
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
function run(command, args, options = {}) {
|
|
53
76
|
const result = spawnSync(command, args, {
|
|
54
77
|
cwd: options.cwd || repoRoot,
|
|
@@ -183,6 +206,14 @@ function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget)
|
|
|
183
206
|
});
|
|
184
207
|
}
|
|
185
208
|
|
|
209
|
+
function peerBindingRow(peer) {
|
|
210
|
+
return withMeshDb((db) => db.prepare(`
|
|
211
|
+
SELECT peer, transport, runtime_target, updated_at
|
|
212
|
+
FROM peer_bindings
|
|
213
|
+
WHERE peer = ?
|
|
214
|
+
`).get(peer));
|
|
215
|
+
}
|
|
216
|
+
|
|
186
217
|
function moveRuntimeBindingPeer(fromPeer, toPeer) {
|
|
187
218
|
withMeshDb((db) => {
|
|
188
219
|
const peer = db.prepare('SELECT * FROM peers WHERE id = ?').get(fromPeer);
|
|
@@ -270,6 +301,162 @@ function assertPeerBindingUniqueConstraints() {
|
|
|
270
301
|
cleanupBindingPeers(runtimePrefix);
|
|
271
302
|
}
|
|
272
303
|
|
|
304
|
+
async function assertTmuxGcPolicy() {
|
|
305
|
+
const stalePeer = 'tmux-gc-stale';
|
|
306
|
+
const peerFilterPeer = 'tmux-gc-peer-filter';
|
|
307
|
+
const attachedPeer = 'tmux-gc-attached';
|
|
308
|
+
const eventPeer = 'tmux-gc-event';
|
|
309
|
+
const reusedEventPeer = 'tmux-gc-event-reused';
|
|
310
|
+
const manualSession = `hcc-${shortHash(root)}-manual-lookalike`;
|
|
311
|
+
const staleSession = tmuxManagedSession(root, stalePeer);
|
|
312
|
+
const peerFilterSession = tmuxManagedSession(root, peerFilterPeer);
|
|
313
|
+
const attachedSession = tmuxManagedSession(root, attachedPeer);
|
|
314
|
+
const eventSession = `${tmuxManagedSession(root, eventPeer)}-old-regression`;
|
|
315
|
+
const reusedEventSession = tmuxManagedSession(root, reusedEventPeer);
|
|
316
|
+
for (const session of [staleSession, peerFilterSession, attachedSession, eventSession, reusedEventSession, manualSession]) {
|
|
317
|
+
runMaybe('tmux', ['kill-session', '-t', session]);
|
|
318
|
+
}
|
|
319
|
+
cleanupBindingPeers('tmux-gc-');
|
|
320
|
+
managedTmuxSessions.add(peerFilterSession);
|
|
321
|
+
managedTmuxSessions.add(attachedSession);
|
|
322
|
+
managedTmuxSessions.add(eventSession);
|
|
323
|
+
managedTmuxSessions.add(reusedEventSession);
|
|
324
|
+
managedTmuxSessions.add(manualSession);
|
|
325
|
+
|
|
326
|
+
run('tmux', ['new-session', '-d', '-s', staleSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
327
|
+
run('tmux', ['new-session', '-d', '-s', peerFilterSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
328
|
+
run('tmux', ['new-session', '-d', '-s', attachedSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
329
|
+
run('tmux', ['new-session', '-d', '-s', eventSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
330
|
+
run('tmux', ['new-session', '-d', '-s', reusedEventSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
331
|
+
run('tmux', ['new-session', '-d', '-s', manualSession, '-e', `HCC_ROOT=${root}`, '-c', root, 'bash', '--noprofile', '--norc']);
|
|
332
|
+
const stalePane = run('tmux', ['display-message', '-p', '-t', `${staleSession}:0.0`, '#{pane_id}']).trim();
|
|
333
|
+
const peerFilterPane = run('tmux', ['display-message', '-p', '-t', `${peerFilterSession}:0.0`, '#{pane_id}']).trim();
|
|
334
|
+
const attachedPane = run('tmux', ['display-message', '-p', '-t', `${attachedSession}:0.0`, '#{pane_id}']).trim();
|
|
335
|
+
const eventPane = run('tmux', ['display-message', '-p', '-t', `${eventSession}:0.0`, '#{pane_id}']).trim();
|
|
336
|
+
const reusedEventPane = run('tmux', ['display-message', '-p', '-t', `${reusedEventSession}:0.0`, '#{pane_id}']).trim();
|
|
337
|
+
const manualPane = run('tmux', ['display-message', '-p', '-t', `${manualSession}:0.0`, '#{pane_id}']).trim();
|
|
338
|
+
insertRuntimeTargetBinding(stalePeer, 'shell', 'tmux-gc-stale-session', stalePane);
|
|
339
|
+
insertRuntimeTargetBinding(peerFilterPeer, 'shell', 'tmux-gc-peer-filter-session', peerFilterPane);
|
|
340
|
+
insertRuntimeTargetBinding(attachedPeer, 'shell', 'tmux-gc-attached-session', attachedPane);
|
|
341
|
+
const eventActivePane = parsePane(hcc(['peer', 'start', eventPeer, '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
342
|
+
const eventActiveSession = run('tmux', ['display-message', '-p', '-t', eventActivePane, '#{session_name}']).trim();
|
|
343
|
+
withMeshDb((db) => {
|
|
344
|
+
const t = Math.floor(Date.now() / 1000) - 30 * 86400;
|
|
345
|
+
db.prepare('UPDATE peers SET last_seen_at = ? WHERE id IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
|
|
346
|
+
db.prepare('UPDATE peer_bindings SET updated_at = ? WHERE peer IN (?, ?, ?)').run(t, stalePeer, peerFilterPeer, attachedPeer);
|
|
347
|
+
db.prepare(`
|
|
348
|
+
INSERT INTO events(type, actor, task_id, payload, created_at)
|
|
349
|
+
VALUES ('tmux.session.rebind_cleanup_failed', ?, NULL, ?, ?)
|
|
350
|
+
`).run(eventPeer, JSON.stringify({
|
|
351
|
+
reason: 'has_clients',
|
|
352
|
+
old_peer: eventPeer,
|
|
353
|
+
old_runtime_target: eventPane,
|
|
354
|
+
new_runtime_target: eventActivePane,
|
|
355
|
+
old_tmux_session: eventSession
|
|
356
|
+
}), t);
|
|
357
|
+
db.prepare(`
|
|
358
|
+
INSERT INTO events(type, actor, task_id, payload, created_at)
|
|
359
|
+
VALUES ('tmux.session.rebind_cleanup_failed', ?, NULL, ?, ?)
|
|
360
|
+
`).run(reusedEventPeer, JSON.stringify({
|
|
361
|
+
reason: 'has_clients',
|
|
362
|
+
old_peer: reusedEventPeer,
|
|
363
|
+
old_runtime_target: `%dead-old-${process.pid}`,
|
|
364
|
+
new_runtime_target: `%new-${process.pid}`,
|
|
365
|
+
old_tmux_session: reusedEventSession
|
|
366
|
+
}), t);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const client = spawnSync('tmux', ['new-session', '-d', '-s', `${tmuxSession}-gc-client`, '-e', `HCC_ROOT=${root}`, 'sh', '-lc', `unset TMUX; exec tmux attach-session -t ${sh(attachedSession)}`], {
|
|
370
|
+
env,
|
|
371
|
+
encoding: 'utf8',
|
|
372
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
373
|
+
});
|
|
374
|
+
if (client.status !== 0) fail(`could not attach client for tmux gc policy test:\n${client.stderr || client.stdout}`);
|
|
375
|
+
try {
|
|
376
|
+
await waitFor(() => Boolean(run('tmux', ['list-clients', '-t', attachedSession, '-F', '#{client_tty}']).trim()), 'tmux gc attached-client setup');
|
|
377
|
+
const dryRun = hcc(['tmux', 'gc', '--older-than', '14']);
|
|
378
|
+
if (!dryRun.includes(stalePeer) || !dryRun.includes('stale_hcc_managed_session') ||
|
|
379
|
+
!dryRun.includes(peerFilterPeer) ||
|
|
380
|
+
!dryRun.includes(eventPeer) || !dryRun.includes('stale_rebind_cleanup_failed_session')) {
|
|
381
|
+
fail(`tmux gc dry-run did not list stale DB-proven managed session:\n${dryRun}`);
|
|
382
|
+
}
|
|
383
|
+
if (dryRun.includes(reusedEventPeer)) {
|
|
384
|
+
fail(`tmux gc dry-run treated stale event session-name reuse as removable:\n${dryRun}`);
|
|
385
|
+
}
|
|
386
|
+
if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0) {
|
|
387
|
+
fail('tmux gc dry-run deleted stale managed session');
|
|
388
|
+
}
|
|
389
|
+
const filteredDryRun = hcc(['tmux', 'gc', '--older-than', '14', '--peer', peerFilterPeer]);
|
|
390
|
+
if (!filteredDryRun.includes(peerFilterPeer) || filteredDryRun.includes(stalePeer) || filteredDryRun.includes(eventPeer)) {
|
|
391
|
+
fail(`tmux gc --peer dry-run did not filter to requested peer:\n${filteredDryRun}`);
|
|
392
|
+
}
|
|
393
|
+
const filteredRemoved = hcc(['tmux', 'gc', '--older-than', '14', '--peer', peerFilterPeer, '--yes']);
|
|
394
|
+
if (!filteredRemoved.includes(peerFilterPeer) || filteredRemoved.includes(stalePeer) || filteredRemoved.includes(eventPeer)) {
|
|
395
|
+
fail(`tmux gc --peer --yes did not remove only requested peer:\n${filteredRemoved}`);
|
|
396
|
+
}
|
|
397
|
+
if (runMaybe('tmux', ['has-session', '-t', peerFilterSession]).status === 0) {
|
|
398
|
+
fail('tmux gc --peer --yes did not delete requested stale managed session');
|
|
399
|
+
}
|
|
400
|
+
if (runMaybe('tmux', ['has-session', '-t', staleSession]).status !== 0 ||
|
|
401
|
+
runMaybe('tmux', ['has-session', '-t', eventSession]).status !== 0) {
|
|
402
|
+
fail('tmux gc --peer --yes deleted an unrequested stale managed session');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const removed = hcc(['tmux', 'gc', '--older-than', '14', '--yes']);
|
|
406
|
+
if (!removed.includes(stalePeer) || !removed.includes(eventPeer) || removed.includes(peerFilterPeer) || removed.includes(attachedPeer) || removed.includes(manualPane)) {
|
|
407
|
+
fail(`tmux gc --yes output wrong:\n${removed}`);
|
|
408
|
+
}
|
|
409
|
+
if (runMaybe('tmux', ['has-session', '-t', staleSession]).status === 0) {
|
|
410
|
+
fail('tmux gc --yes did not delete stale DB-proven managed session');
|
|
411
|
+
}
|
|
412
|
+
if (runMaybe('tmux', ['has-session', '-t', eventSession]).status === 0) {
|
|
413
|
+
fail('tmux gc --yes did not delete stale rebind cleanup-failed session');
|
|
414
|
+
}
|
|
415
|
+
if (runMaybe('tmux', ['has-session', '-t', eventActiveSession]).status !== 0) {
|
|
416
|
+
fail('tmux gc deleted the active replacement session for the same peer');
|
|
417
|
+
}
|
|
418
|
+
if (runMaybe('tmux', ['has-session', '-t', reusedEventSession]).status !== 0) {
|
|
419
|
+
fail('tmux gc deleted a same-name session whose old runtime target no longer matched');
|
|
420
|
+
}
|
|
421
|
+
if (run('tmux', ['display-message', '-p', '-t', `${reusedEventSession}:0.0`, '#{pane_id}']).trim() !== reusedEventPane) {
|
|
422
|
+
fail('tmux gc touched the wrong same-name rebind cleanup session');
|
|
423
|
+
}
|
|
424
|
+
if (runMaybe('tmux', ['has-session', '-t', attachedSession]).status !== 0) {
|
|
425
|
+
fail('tmux gc deleted managed session with attached tmux client');
|
|
426
|
+
}
|
|
427
|
+
if (runMaybe('tmux', ['has-session', '-t', manualSession]).status !== 0) {
|
|
428
|
+
fail('tmux gc deleted manual lookalike tmux session without DB binding');
|
|
429
|
+
}
|
|
430
|
+
const staleBinding = peerBindingRow(stalePeer);
|
|
431
|
+
if (!staleBinding || staleBinding.transport !== 'detached' || staleBinding.runtime_target !== null) {
|
|
432
|
+
fail(`tmux gc did not detach deleted binding:\n${JSON.stringify(staleBinding, null, 2)}`);
|
|
433
|
+
}
|
|
434
|
+
const peerFilterBinding = peerBindingRow(peerFilterPeer);
|
|
435
|
+
if (!peerFilterBinding || peerFilterBinding.transport !== 'detached' || peerFilterBinding.runtime_target !== null) {
|
|
436
|
+
fail(`tmux gc --peer did not detach requested deleted binding:\n${JSON.stringify(peerFilterBinding, null, 2)}`);
|
|
437
|
+
}
|
|
438
|
+
const attachedBinding = peerBindingRow(attachedPeer);
|
|
439
|
+
if (!attachedBinding || attachedBinding.runtime_target !== attachedPane) {
|
|
440
|
+
fail(`tmux gc changed attached-client binding:\n${JSON.stringify(attachedBinding, null, 2)}`);
|
|
441
|
+
}
|
|
442
|
+
const eventBinding = peerBindingRow(eventPeer);
|
|
443
|
+
if (!eventBinding || eventBinding.runtime_target !== eventActivePane) {
|
|
444
|
+
fail(`tmux gc changed active replacement binding for event-backed stale session:\n${JSON.stringify(eventBinding, null, 2)}`);
|
|
445
|
+
}
|
|
446
|
+
} finally {
|
|
447
|
+
hccMaybe(['peer', 'stop', eventPeer, '--kill-tmux']);
|
|
448
|
+
runMaybe('tmux', ['kill-session', '-t', `${tmuxSession}-gc-client`]);
|
|
449
|
+
runMaybe('tmux', ['kill-session', '-t', peerFilterSession]);
|
|
450
|
+
runMaybe('tmux', ['kill-session', '-t', attachedSession]);
|
|
451
|
+
runMaybe('tmux', ['kill-session', '-t', eventSession]);
|
|
452
|
+
runMaybe('tmux', ['kill-session', '-t', reusedEventSession]);
|
|
453
|
+
runMaybe('tmux', ['kill-session', '-t', eventActiveSession]);
|
|
454
|
+
runMaybe('tmux', ['kill-session', '-t', manualSession]);
|
|
455
|
+
runMaybe('tmux', ['kill-session', '-t', staleSession]);
|
|
456
|
+
cleanupBindingPeers('tmux-gc-');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
273
460
|
function createLegacyBindingDb(dbPath) {
|
|
274
461
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
275
462
|
const db = new DatabaseSync(dbPath);
|
|
@@ -592,6 +779,26 @@ function trackTmuxPane(pane) {
|
|
|
592
779
|
if (result.status === 0) managedTmuxSessions.add(result.stdout.trim());
|
|
593
780
|
}
|
|
594
781
|
|
|
782
|
+
function tmuxEnvironmentValue(session, key) {
|
|
783
|
+
const result = runMaybe('tmux', ['show-environment', '-t', session, key]);
|
|
784
|
+
if (result.status !== 0) return null;
|
|
785
|
+
const line = (result.stdout || '').trim();
|
|
786
|
+
const prefix = `${key}=`;
|
|
787
|
+
return line.startsWith(prefix) ? line.slice(prefix.length) : null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function assertHccManagedTmuxEnv(pane, expectedRoot = root) {
|
|
791
|
+
const session = run('tmux', ['display-message', '-p', '-t', pane, '#{session_name}']).trim();
|
|
792
|
+
const hccRoot = tmuxEnvironmentValue(session, 'HCC_ROOT');
|
|
793
|
+
const hccDb = tmuxEnvironmentValue(session, 'HCC_DB');
|
|
794
|
+
const expectedDb = path.join(expectedRoot, '.hello-cc', 'mesh.db');
|
|
795
|
+
if (path.resolve(hccRoot || '') !== path.resolve(expectedRoot) ||
|
|
796
|
+
path.resolve(hccDb || '') !== path.resolve(expectedDb)) {
|
|
797
|
+
fail(`hcc-managed tmux session missing root/db markers: ${session} HCC_ROOT=${hccRoot} HCC_DB=${hccDb}`);
|
|
798
|
+
}
|
|
799
|
+
return session;
|
|
800
|
+
}
|
|
801
|
+
|
|
595
802
|
function parsePane(output) {
|
|
596
803
|
const match = String(output).match(/\bpane=(%\d+)/);
|
|
597
804
|
if (!match) fail(`cannot parse tmux pane from:\n${output}`);
|
|
@@ -888,6 +1095,10 @@ async function expectBoundedTmuxStream(label) {
|
|
|
888
1095
|
|
|
889
1096
|
function writeFakeTools() {
|
|
890
1097
|
fs.mkdirSync(fakeBin, { recursive: true });
|
|
1098
|
+
if (realTmuxBin) {
|
|
1099
|
+
const tmuxWrapper = path.join(fakeBin, 'tmux');
|
|
1100
|
+
fs.writeFileSync(tmuxWrapper, `#!/usr/bin/env bash\nexec ${sh(realTmuxBin)} -L ${sh(tmuxSocketName)} "$@"\n`, { mode: 0o755 });
|
|
1101
|
+
}
|
|
891
1102
|
for (const name of ['claude', 'codex']) {
|
|
892
1103
|
const file = path.join(fakeBin, name);
|
|
893
1104
|
fs.writeFileSync(file, `#!/usr/bin/env bash\necho fake-${name} "$@"\nif [ -n "\${HCC_FAKE_LOG:-}" ]; then echo fake-${name} "$@" >> "$HCC_FAKE_LOG"; fi\nif [ "\${HCC_FAKE_STAY_ALIVE:-}" = "1" ]; then exec bash --noprofile --norc; fi\n`, { mode: 0o755 });
|
|
@@ -934,6 +1145,9 @@ function cleanup() {
|
|
|
934
1145
|
for (const session of managedTmuxSessions) {
|
|
935
1146
|
runMaybe('tmux', ['kill-session', '-t', session]);
|
|
936
1147
|
}
|
|
1148
|
+
if (fs.existsSync(path.join(fakeBin, 'tmux'))) {
|
|
1149
|
+
runMaybe('tmux', ['kill-server']);
|
|
1150
|
+
}
|
|
937
1151
|
for (const dir of [root, home, fakeBin, outDir]) {
|
|
938
1152
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
939
1153
|
}
|
|
@@ -1687,6 +1901,11 @@ async function multiProjectWebWorkflow() {
|
|
|
1687
1901
|
if (!(otherSessions.sessions || []).some((s) => s.id === 'other-shell')) {
|
|
1688
1902
|
fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
|
|
1689
1903
|
}
|
|
1904
|
+
const otherShellSession = (otherSessions.sessions || []).find((s) => s.id === 'other-shell');
|
|
1905
|
+
if (!otherShellSession?.binding || otherShellSession.binding.runtime_target !== otherShellSession.pane ||
|
|
1906
|
+
otherShellSession.provider_session_known !== false || otherShellSession.provider_session_label !== null) {
|
|
1907
|
+
fail(`sessions API did not expose unknown provider binding without marking it shared/known:\n${JSON.stringify(otherShellSession, null, 2)}`);
|
|
1908
|
+
}
|
|
1690
1909
|
|
|
1691
1910
|
hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
|
|
1692
1911
|
hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
|
|
@@ -1753,6 +1972,9 @@ async function multiProjectWebWorkflow() {
|
|
|
1753
1972
|
if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
|
|
1754
1973
|
fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1755
1974
|
}
|
|
1975
|
+
if (!claudeResume.binding || !claudeResume.provider_session_known || claudeResume.provider_session_label !== claudeResumeName) {
|
|
1976
|
+
fail(`web claude resume session response did not include known provider binding:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1977
|
+
}
|
|
1756
1978
|
const claudeRows = providerBindingRows('claude', claudeResumeName);
|
|
1757
1979
|
if (claudeRows.length !== 1 ||
|
|
1758
1980
|
claudeRows[0].peer !== claudeResume.peer_id ||
|
|
@@ -1773,6 +1995,9 @@ async function multiProjectWebWorkflow() {
|
|
|
1773
1995
|
if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
|
|
1774
1996
|
fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
1775
1997
|
}
|
|
1998
|
+
if (!codexResume.binding || !codexResume.provider_session_known || codexResume.provider_session_label !== codexResumeName) {
|
|
1999
|
+
fail(`web codex resume session response did not include known provider binding:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
2000
|
+
}
|
|
1776
2001
|
const codexRows = providerBindingRows('codex', codexResumeName);
|
|
1777
2002
|
if (codexRows.length !== 1 ||
|
|
1778
2003
|
codexRows[0].peer !== codexResume.peer_id ||
|
|
@@ -1852,6 +2077,7 @@ async function tmuxBackedStartWorkflow() {
|
|
|
1852
2077
|
const file = path.join(outDir, 'pty-ok');
|
|
1853
2078
|
const started = hcc(['peer', 'start', 'shell-a', '--kind', 'shell', '--', 'bash']);
|
|
1854
2079
|
const pane = parsePane(started);
|
|
2080
|
+
assertHccManagedTmuxEnv(pane);
|
|
1855
2081
|
const list = hcc(['peer', 'list']);
|
|
1856
2082
|
if (!list.includes('shell-a') || !list.includes('tmux')) fail(`tmux-backed peer missing from list:\n${list}`);
|
|
1857
2083
|
hcc(['inject', 'shell-a', `echo PTY_OK > ${file}`]);
|
|
@@ -1893,6 +2119,7 @@ async function tmuxBackedStartWorkflow() {
|
|
|
1893
2119
|
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
1894
2120
|
});
|
|
1895
2121
|
const canonicalPane = parsePane(canonicalStarted);
|
|
2122
|
+
assertHccManagedTmuxEnv(canonicalPane);
|
|
1896
2123
|
const canonicalRows = providerBindingRows('claude', canonicalSession);
|
|
1897
2124
|
if (canonicalRows.length !== 1 ||
|
|
1898
2125
|
canonicalRows[0].peer !== 'claude-live-canonical' ||
|
|
@@ -1906,17 +2133,22 @@ async function tmuxBackedStartWorkflow() {
|
|
|
1906
2133
|
const forceFirst = hcc(['peer', 'start', 'claude-force-a', '--kind', 'claude', '--resume', forceSession], {
|
|
1907
2134
|
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
1908
2135
|
});
|
|
1909
|
-
parsePane(forceFirst);
|
|
2136
|
+
const forceFirstPane = parsePane(forceFirst);
|
|
2137
|
+
const forceFirstSession = run('tmux', ['display-message', '-p', '-t', forceFirstPane, '#{session_name}']).trim();
|
|
1910
2138
|
const forceConflict = hccMaybe(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession], {
|
|
1911
2139
|
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
1912
2140
|
});
|
|
1913
2141
|
if (forceConflict.status === 0 || !String(forceConflict.stderr || forceConflict.stdout).includes('already bound to claude-force-a')) {
|
|
1914
2142
|
fail(`provider session duplicate start was not rejected:\n${forceConflict.stdout}\n${forceConflict.stderr}`);
|
|
1915
2143
|
}
|
|
2144
|
+
if (runMaybe('tmux', ['has-session', '-t', tmuxManagedSession(root, 'claude-force-b')]).status === 0) {
|
|
2145
|
+
fail('failed duplicate provider start left a new claude-force-b tmux session behind');
|
|
2146
|
+
}
|
|
1916
2147
|
const forceSecond = hcc(['peer', 'start', 'claude-force-b', '--kind', 'claude', '--resume', forceSession, '--force'], {
|
|
1917
2148
|
env: { ...env, HCC_FAKE_STAY_ALIVE: '1' }
|
|
1918
2149
|
});
|
|
1919
2150
|
const forcePane = parsePane(forceSecond);
|
|
2151
|
+
assertHccManagedTmuxEnv(forcePane);
|
|
1920
2152
|
const forceRows = providerBindingRows('claude', forceSession);
|
|
1921
2153
|
if (forceRows.length !== 1 ||
|
|
1922
2154
|
forceRows[0].peer !== 'claude-force-b' ||
|
|
@@ -1924,6 +2156,9 @@ async function tmuxBackedStartWorkflow() {
|
|
|
1924
2156
|
forceRows[0].runtime_target !== forcePane) {
|
|
1925
2157
|
fail(`--force did not move provider binding to replacement tmux peer:\n${JSON.stringify(forceRows, null, 2)}`);
|
|
1926
2158
|
}
|
|
2159
|
+
if (runMaybe('tmux', ['has-session', '-t', forceFirstSession]).status === 0) {
|
|
2160
|
+
fail('--force provider rebind did not remove old hcc-managed tmux session');
|
|
2161
|
+
}
|
|
1927
2162
|
hccMaybe(['peer', 'stop', 'claude-force-a']);
|
|
1928
2163
|
hcc(['peer', 'stop', 'claude-force-b']);
|
|
1929
2164
|
|
|
@@ -1931,12 +2166,14 @@ async function tmuxBackedStartWorkflow() {
|
|
|
1931
2166
|
startRuntime({ env: envWithoutProvider({ HCC_REG_VALUE: 'runtime-old', ANTHROPIC_BASE_URL: 'runtime-old-url' }) });
|
|
1932
2167
|
await waitRuntime();
|
|
1933
2168
|
const envFile = path.join(outDir, 'caller-env-ok');
|
|
1934
|
-
parsePane(hcc(['peer', 'start', 'env-a', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], {
|
|
2169
|
+
const envPane = parsePane(hcc(['peer', 'start', 'env-a', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], {
|
|
1935
2170
|
env: envWithoutProvider({ HCC_REG_VALUE: 'caller-new' })
|
|
1936
2171
|
}));
|
|
2172
|
+
assertHccManagedTmuxEnv(envPane);
|
|
1937
2173
|
hcc(['inject', 'env-a', `printf '%s|%s\\n' "$HCC_REG_VALUE" "\${ANTHROPIC_BASE_URL:-}" > ${envFile}`]);
|
|
1938
2174
|
await waitForFile(envFile, 'caller-new|', 'caller env propagation');
|
|
1939
2175
|
hcc(['peer', 'stop', 'env-a']);
|
|
2176
|
+
await assertTmuxGcPolicy();
|
|
1940
2177
|
}
|
|
1941
2178
|
|
|
1942
2179
|
async function shimTmuxWorkflow() {
|
|
@@ -2184,7 +2421,17 @@ async function syntaxAndHelp() {
|
|
|
2184
2421
|
run(process.execPath, ['--check', path.join(repoRoot, file)]);
|
|
2185
2422
|
}
|
|
2186
2423
|
const hccSource = fs.readFileSync(hccBin, 'utf8');
|
|
2187
|
-
const
|
|
2424
|
+
const coordinationStateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'coordination-state.mjs'), 'utf8');
|
|
2425
|
+
const errorsCompatSource = fs.readFileSync(path.join(repoRoot, 'lib', 'errors.mjs'), 'utf8');
|
|
2426
|
+
const sharedErrorsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shared', 'errors.mjs'), 'utf8');
|
|
2427
|
+
const setupSource = fs.readFileSync(path.join(repoRoot, 'lib', 'setup.mjs'), 'utf8');
|
|
2428
|
+
const shimScriptCompatSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shim-script.mjs'), 'utf8');
|
|
2429
|
+
const integrationHooksSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'hooks.mjs'), 'utf8');
|
|
2430
|
+
const integrationShimsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims.mjs'), 'utf8');
|
|
2431
|
+
const integrationShimScriptSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'), 'utf8');
|
|
2432
|
+
const shellPathSource = fs.readFileSync(path.join(repoRoot, 'lib', 'shell-path.mjs'), 'utf8');
|
|
2433
|
+
const webPeerActionsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'peer-actions.mjs'), 'utf8');
|
|
2434
|
+
const webUiTemplateSource = fs.readFileSync(path.join(repoRoot, 'lib', 'web', 'ui-template.mjs'), 'utf8');
|
|
2188
2435
|
for (const expected of [
|
|
2189
2436
|
'function scheduleTmuxInputRefresh(session)',
|
|
2190
2437
|
"runTmux(['pipe-pane', '-t', session.pane]);",
|
|
@@ -2196,11 +2443,134 @@ async function syntaxAndHelp() {
|
|
|
2196
2443
|
]) {
|
|
2197
2444
|
if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
|
|
2198
2445
|
}
|
|
2446
|
+
for (const expected of [
|
|
2447
|
+
'function listTmuxPanesOnce()',
|
|
2448
|
+
'let autoAttachScanInFlight = false;',
|
|
2449
|
+
'if (autoAttachScanInFlight) return;',
|
|
2450
|
+
'const paneByPid = new Map();',
|
|
2451
|
+
'const attached = attachedTmuxState(ctx, db);',
|
|
2452
|
+
'function killOldTmuxForRebind(',
|
|
2453
|
+
'function providerSessionBindingMatches(',
|
|
2454
|
+
'function hccWebProcessMatches(',
|
|
2455
|
+
'function splitProcessArgs(line)',
|
|
2456
|
+
'const { global, rest } = splitGlobalArgs(hccArgs);',
|
|
2457
|
+
"if (rest[0] !== 'web') return false;",
|
|
2458
|
+
'sameResolvedPath(global.root, ctx.root)',
|
|
2459
|
+
'sameResolvedPath(global.db, ctx.dbPath)',
|
|
2460
|
+
'sameResolvedPath(',
|
|
2461
|
+
'async function stopOrphanWebRuntimes(',
|
|
2462
|
+
'await stopOrphanWebRuntimes(ctx, existing.pid || null);',
|
|
2463
|
+
'await stopOrphanWebRuntimes(ctx);',
|
|
2464
|
+
'const restoredTmuxDbs = new Set();',
|
|
2465
|
+
'if (restoredTmuxDbs.has(dbKey)) continue;',
|
|
2466
|
+
'input.rebindOldTmux',
|
|
2467
|
+
'rebindOldTmux: true',
|
|
2468
|
+
'skipProviderRebindCleanup:',
|
|
2469
|
+
'function safeOldTmuxRebindPlan(',
|
|
2470
|
+
'function assertOldTmuxCanRebind(',
|
|
2471
|
+
'TMUX_REBIND_NOT_HCC_MANAGED',
|
|
2472
|
+
'TMUX_REBIND_ROOT_MISMATCH',
|
|
2473
|
+
"addEvent(db, 'tmux.session.rebind_cleanup_pending'",
|
|
2474
|
+
'TMUX_REBIND_OLD_SESSION_IN_USE',
|
|
2475
|
+
"addEvent(db, 'tmux.session.rebind_cleanup_failed'",
|
|
2476
|
+
'function restoreParkedOldTmuxSessions()',
|
|
2477
|
+
'restoreParkedOldTmuxSessions();',
|
|
2478
|
+
'parkedOldTmuxSessions.push',
|
|
2479
|
+
"addEvent(db, 'tmux.session.rebound'",
|
|
2480
|
+
"addEvent(eventDb, 'provider.session.rebound'"
|
|
2481
|
+
]) {
|
|
2482
|
+
if (!hccSource.includes(expected)) fail(`tmux auto-attach/rebind guard missing: ${expected}`);
|
|
2483
|
+
}
|
|
2484
|
+
if (hccSource.includes("return line.includes(`--root ${root}`) || line.includes(`--db ${db}`);")) {
|
|
2485
|
+
fail('orphan web runtime cleanup still uses substring matching for --root/--db');
|
|
2486
|
+
}
|
|
2487
|
+
for (const expected of [
|
|
2488
|
+
'function safeTmuxKillPlan(projectCtx, db, peerId, expectedTarget)',
|
|
2489
|
+
"binding.transport !== 'tmux'",
|
|
2490
|
+
'const expectedSession = tmuxManagedSessionName(projectCtx, peerId);',
|
|
2491
|
+
"throw new CliError('TMUX_KILL_NOT_HCC_MANAGED'",
|
|
2492
|
+
"throw new CliError('TMUX_KILL_ROOT_MISMATCH'",
|
|
2493
|
+
"throw new CliError('TMUX_KILL_HAS_CLIENTS'",
|
|
2494
|
+
'killDbProvenTmuxSession(reqCtx, db, peerId)',
|
|
2495
|
+
'safeTmuxKillPlan(reqCtx, stopDb, peerId, session.pane || null)'
|
|
2496
|
+
]) {
|
|
2497
|
+
if (!hccSource.includes(expected)) fail(`tmux kill safety guard missing: ${expected}`);
|
|
2498
|
+
}
|
|
2499
|
+
if (hccSource.includes("if (sessName) tmuxKillSession(sessName);")) {
|
|
2500
|
+
fail('web stop kill_tmux still kills tmux by session name without DB-proven guard');
|
|
2501
|
+
}
|
|
2502
|
+
for (const expected of [
|
|
2503
|
+
'async function cmdTmux(',
|
|
2504
|
+
'async function planTmuxGc(',
|
|
2505
|
+
"validateOpts('tmux gc'",
|
|
2506
|
+
'const dryRun = !opts.yes;',
|
|
2507
|
+
'const targetPeer = opts.peer || null;',
|
|
2508
|
+
'if (targetPeer && row.peer !== targetPeer) continue;',
|
|
2509
|
+
'if (targetPeer && rowPeer !== targetPeer) continue;',
|
|
2510
|
+
"b.transport = 'tmux'",
|
|
2511
|
+
'b.runtime_target IS NOT NULL',
|
|
2512
|
+
'actualSession !== expectedSession',
|
|
2513
|
+
"const hccRoot = tmuxSessionEnvironmentValue(actualSession, 'HCC_ROOT');",
|
|
2514
|
+
"skip('hcc_root_mismatch'",
|
|
2515
|
+
"skip('runtime_managed')",
|
|
2516
|
+
"skip('has_tmux_clients'",
|
|
2517
|
+
"reason: 'stale_hcc_managed_session'",
|
|
2518
|
+
"type IN ('tmux.session.rebind_cleanup_failed', 'tmux.session.rebind_cleanup_pending')",
|
|
2519
|
+
"'stale_rebind_cleanup_failed_session'",
|
|
2520
|
+
"'rebind_cleanup_failed'",
|
|
2521
|
+
"'rebind_cleanup_pending'",
|
|
2522
|
+
"'stale_rebind_cleanup_pending_session'",
|
|
2523
|
+
"SET transport = 'detached'",
|
|
2524
|
+
"addEvent(db, 'tmux.session.gc'"
|
|
2525
|
+
]) {
|
|
2526
|
+
if (!hccSource.includes(expected)) fail(`tmux gc guard missing: ${expected}`);
|
|
2527
|
+
}
|
|
2528
|
+
const autoAttachSource = hccSource.slice(
|
|
2529
|
+
hccSource.indexOf('function scanAndAttachDetectedPeers()'),
|
|
2530
|
+
hccSource.indexOf('scanAndAttachDetectedPeers();')
|
|
2531
|
+
);
|
|
2532
|
+
if ((autoAttachSource.match(/runTmux\(\['list-panes'/g) || []).length > 0) {
|
|
2533
|
+
fail('auto-attach scan calls tmux list-panes inside scanAndAttachDetectedPeers instead of using listTmuxPanesOnce');
|
|
2534
|
+
}
|
|
2535
|
+
if (!autoAttachSource.includes('panes = listTmuxPanesOnce();')) {
|
|
2536
|
+
fail('auto-attach scan no longer uses one tmux pane listing per tick');
|
|
2537
|
+
}
|
|
2538
|
+
const managedStartSource = hccSource.slice(
|
|
2539
|
+
hccSource.indexOf('function startTmuxManagedSession(input)'),
|
|
2540
|
+
hccSource.indexOf('function restoreTmuxManagedSessions')
|
|
2541
|
+
);
|
|
2542
|
+
if (!managedStartSource.includes('rebindOldTmux: true')) {
|
|
2543
|
+
fail('managed tmux start no longer marks explicit rebind cleanup');
|
|
2544
|
+
}
|
|
2545
|
+
if (!managedStartSource.includes('skipProviderRebindCleanup: oldTmuxTargetsForRebind.length > 0')) {
|
|
2546
|
+
fail('managed tmux start no longer avoids duplicate provider rebind cleanup planning');
|
|
2547
|
+
}
|
|
2548
|
+
for (const expected of [
|
|
2549
|
+
'HCC_ROOT: pctx.root',
|
|
2550
|
+
'HCC_DB: pctx.dbPath',
|
|
2551
|
+
'[LAUNCH_FINGERPRINT_ENV]: env[LAUNCH_FINGERPRINT_ENV]'
|
|
2552
|
+
]) {
|
|
2553
|
+
if (!managedStartSource.includes(expected)) fail(`managed tmux start no longer marks session env: ${expected}`);
|
|
2554
|
+
}
|
|
2555
|
+
const restoreSource = hccSource.slice(
|
|
2556
|
+
hccSource.indexOf('function restoreTmuxManagedSessions'),
|
|
2557
|
+
hccSource.indexOf('function startPtySession')
|
|
2558
|
+
);
|
|
2559
|
+
if (restoreSource.includes('rebindOldTmux')) {
|
|
2560
|
+
fail('restore path must not kill old tmux sessions as a rebind');
|
|
2561
|
+
}
|
|
2199
2562
|
for (const expected of [
|
|
2200
2563
|
'function detectedPeerCanStop(peer)',
|
|
2201
2564
|
"if (['exited', 'detached'].includes(status)) return false;",
|
|
2202
2565
|
'const canStop = detectedPeerCanStop(p);',
|
|
2203
2566
|
'${canStop ?',
|
|
2567
|
+
'function providerSessionKnown(session)',
|
|
2568
|
+
"tr('providerSession') + '=' + sessionProvider(session) + ':' + (value || tr('unknown'))",
|
|
2569
|
+
'sessionCardDetailText(s)',
|
|
2570
|
+
'const activeDetectedPeers = visibleDetected.filter((p) => peerIsActive(p));',
|
|
2571
|
+
'const staleDetectedPeers = visibleDetected.filter((p) => !peerIsActive(p));',
|
|
2572
|
+
"localStorage.setItem('hcc.showStaleDetected'",
|
|
2573
|
+
'id="toggleStaleDetected"',
|
|
2204
2574
|
"if (e.target.closest('[data-action]')) return;",
|
|
2205
2575
|
'id="stopKillLabel" data-i18n="dialog.killTmux"',
|
|
2206
2576
|
'id="stopCancelBtn" type="button" data-i18n="dialog.cancel"',
|
|
@@ -2211,44 +2581,2071 @@ async function syntaxAndHelp() {
|
|
|
2211
2581
|
if (webUiTemplateSource.includes("p.status === 'running' ?")) {
|
|
2212
2582
|
fail('detected peer action rendering still depends on status === running instead of liveness');
|
|
2213
2583
|
}
|
|
2584
|
+
if (webUiTemplateSource.indexOf("const stopDialog = document.getElementById('stopDialog');") >
|
|
2585
|
+
webUiTemplateSource.indexOf("document.getElementById('stopBtn').addEventListener('click'")) {
|
|
2586
|
+
fail('stop dialog DOM references are declared after the stop button handler uses them');
|
|
2587
|
+
}
|
|
2588
|
+
if (webUiTemplateSource.includes("sessionRuntimeNote(s) + (s.command || '')")) {
|
|
2589
|
+
fail('managed session display still uses command text as the primary runtime identity');
|
|
2590
|
+
}
|
|
2214
2591
|
for (const expected of [
|
|
2215
|
-
'function
|
|
2216
|
-
'function
|
|
2217
|
-
'
|
|
2218
|
-
'
|
|
2592
|
+
'function sessionBindingForSerialize(db, session, peerId)',
|
|
2593
|
+
'function serializeBindingSummary(binding, session)',
|
|
2594
|
+
'provider_session_known: Boolean(providerSessionLabel)',
|
|
2595
|
+
'provider_session_label: providerSessionLabel'
|
|
2596
|
+
]) {
|
|
2597
|
+
if (!hccSource.includes(expected)) fail(`sessions API binding summary missing: ${expected}`);
|
|
2598
|
+
}
|
|
2599
|
+
for (const expected of [
|
|
2600
|
+
'import { createWebPeerActions } from \'../lib/web/peer-actions.mjs\'',
|
|
2601
|
+
'} = createWebPeerActions({',
|
|
2219
2602
|
'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
|
|
2220
2603
|
"const readOnly = ['status', 'state', 'inbox'].includes(action)",
|
|
2221
2604
|
"sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
|
|
2222
2605
|
]) {
|
|
2223
2606
|
if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
|
|
2224
2607
|
}
|
|
2608
|
+
for (const helper of [
|
|
2609
|
+
'function webPeerRegister(',
|
|
2610
|
+
'function webPeerHeartbeat(',
|
|
2611
|
+
'function webPeerTaskNext(',
|
|
2612
|
+
'function webPeerTaskTakeover(',
|
|
2613
|
+
'function webPeerLockAcquire(',
|
|
2614
|
+
'function webPeerLockRelease(',
|
|
2615
|
+
'function webPeerInbox(',
|
|
2616
|
+
'function webPeerAction('
|
|
2617
|
+
]) {
|
|
2618
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds web peer action helper: ${helper}`);
|
|
2619
|
+
}
|
|
2620
|
+
for (const expected of [
|
|
2621
|
+
'function webPeerAction(projectCtx, peer, action, input = {})',
|
|
2622
|
+
'claimNextTasksForPeer(db, peer, { force: Boolean(input.force), count })',
|
|
2623
|
+
'takeOverTaskForPeer(db, peer, id, { reason, policy, staleAfter, source: ',
|
|
2624
|
+
'const status = statusSummary(projectCtx, peer)',
|
|
2625
|
+
"normalized === 'task-next'",
|
|
2626
|
+
"normalized === 'lock-acquire'",
|
|
2627
|
+
"normalized === 'lock-release'"
|
|
2628
|
+
]) {
|
|
2629
|
+
if (!webPeerActionsSource.includes(expected)) fail(`web peer action helper missing: ${expected}`);
|
|
2630
|
+
}
|
|
2225
2631
|
const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
|
|
2226
2632
|
const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
|
|
2227
2633
|
if (cliVersion !== packageVersion) {
|
|
2228
2634
|
fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
|
|
2229
2635
|
}
|
|
2636
|
+
const releasePackageMeta = await import(path.join(repoRoot, 'lib', 'release', 'package-meta.mjs'));
|
|
2637
|
+
const releaseNotesModule = await import(path.join(repoRoot, 'lib', 'release', 'release-notes.mjs'));
|
|
2638
|
+
const compatPackageMeta = await import(path.join(repoRoot, 'lib', 'package-meta.mjs'));
|
|
2639
|
+
const compatReleaseNotes = await import(path.join(repoRoot, 'lib', 'release-notes.mjs'));
|
|
2640
|
+
for (const [moduleName, mod, names] of [
|
|
2641
|
+
['release/package-meta', releasePackageMeta, ['packageRoot', 'readJson', 'readPackageJson', 'readPackageMeta']],
|
|
2642
|
+
['release/release-notes', releaseNotesModule, ['normalizeVersion', 'releaseSection', 'validateReleaseSection', 'repoFromPackage']],
|
|
2643
|
+
['package-meta compat', compatPackageMeta, ['packageRoot', 'readJson', 'readPackageJson', 'readPackageMeta']],
|
|
2644
|
+
['release-notes compat', compatReleaseNotes, ['normalizeVersion', 'releaseSection', 'validateReleaseSection', 'repoFromPackage']]
|
|
2645
|
+
]) {
|
|
2646
|
+
for (const name of names) {
|
|
2647
|
+
if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
const releaseScriptRoot = releasePackageMeta.packageRoot(
|
|
2651
|
+
pathToFileURL(path.join(repoRoot, 'scripts', 'release-notes.mjs')).href
|
|
2652
|
+
);
|
|
2653
|
+
if (releaseScriptRoot !== repoRoot) {
|
|
2654
|
+
fail(`release packageRoot resolved ${releaseScriptRoot}, expected ${repoRoot}`);
|
|
2655
|
+
}
|
|
2656
|
+
const releaseNotesScriptSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'release-notes.mjs'), 'utf8');
|
|
2657
|
+
const githubReleaseScriptSource = fs.readFileSync(path.join(repoRoot, 'scripts', 'github-release.mjs'), 'utf8');
|
|
2658
|
+
if (!releaseNotesScriptSource.includes("../lib/release/package-meta.mjs") ||
|
|
2659
|
+
!releaseNotesScriptSource.includes("../lib/release/release-notes.mjs") ||
|
|
2660
|
+
!githubReleaseScriptSource.includes("../lib/release/package-meta.mjs") ||
|
|
2661
|
+
!githubReleaseScriptSource.includes("../lib/release/release-notes.mjs")) {
|
|
2662
|
+
fail('release scripts do not import release helpers from lib/release');
|
|
2663
|
+
}
|
|
2664
|
+
const dbSchema = await import(path.join(repoRoot, 'lib', 'db', 'schema.mjs'));
|
|
2665
|
+
const compatDbSchema = await import(path.join(repoRoot, 'lib', 'db-schema.mjs'));
|
|
2666
|
+
for (const [moduleName, mod] of [
|
|
2667
|
+
['db/schema', dbSchema],
|
|
2668
|
+
['db-schema compat', compatDbSchema]
|
|
2669
|
+
]) {
|
|
2670
|
+
if (typeof mod.DB_SCHEMA_VERSION !== 'number') fail(`${moduleName} missing DB_SCHEMA_VERSION export`);
|
|
2671
|
+
for (const name of ['execWithBusyRetry', 'initSchema', 'readSchemaVersion', 'tx']) {
|
|
2672
|
+
if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2230
2675
|
if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
|
|
2231
2676
|
!hccSource.includes("} from '../lib/cli-args.mjs'") ||
|
|
2232
|
-
!hccSource.includes("import { CliError } from '../lib/errors.mjs'") ||
|
|
2233
|
-
!hccSource.includes("} from '../lib/db
|
|
2677
|
+
!hccSource.includes("import { CliError } from '../lib/shared/errors.mjs'") ||
|
|
2678
|
+
!hccSource.includes("} from '../lib/db/schema.mjs'") ||
|
|
2679
|
+
!hccSource.includes("} from '../lib/cli-runtime.mjs'") ||
|
|
2680
|
+
!hccSource.includes("import { createCoordinationState } from '../lib/coordination-state.mjs'") ||
|
|
2681
|
+
!hccSource.includes("import { createWebPeerActions } from '../lib/web/peer-actions.mjs'") ||
|
|
2234
2682
|
!hccSource.includes("} from '../lib/format.mjs'") ||
|
|
2235
|
-
!hccSource.includes("} from '../lib/runtime
|
|
2236
|
-
!hccSource.includes("} from '../lib/
|
|
2237
|
-
!hccSource.includes("} from '../lib/
|
|
2238
|
-
!hccSource.includes("
|
|
2683
|
+
!hccSource.includes("} from '../lib/runtime/paths.mjs'") ||
|
|
2684
|
+
!hccSource.includes("} from '../lib/runtime/state.mjs'") ||
|
|
2685
|
+
!hccSource.includes("} from '../lib/project-context.mjs'") ||
|
|
2686
|
+
!hccSource.includes("} from '../lib/handoff.mjs'") ||
|
|
2687
|
+
!hccSource.includes("} from '../lib/core/peers/liveness.mjs'") ||
|
|
2688
|
+
!hccSource.includes("} from '../lib/ui/state-render.mjs'") ||
|
|
2689
|
+
!hccSource.includes("import { createHelpFunctions } from '../lib/ui/help.mjs'") ||
|
|
2690
|
+
!hccSource.includes("import { runtimeRequest } from '../lib/runtime/client.mjs'") ||
|
|
2691
|
+
!hccSource.includes("import { createMessageStore } from '../lib/core/coordination/messages.mjs'") ||
|
|
2692
|
+
!hccSource.includes("import { createTaskStore } from '../lib/core/coordination/tasks.mjs'") ||
|
|
2693
|
+
!hccSource.includes("} from '../lib/task-cli.mjs'") ||
|
|
2694
|
+
!hccSource.includes("} from '../lib/core/sessions/launch.mjs'") ||
|
|
2695
|
+
!hccSource.includes("} from '../lib/integrations/providers.mjs'") ||
|
|
2696
|
+
!hccSource.includes("} from '../lib/core/peers/session.mjs'") ||
|
|
2697
|
+
!hccSource.includes("} from '../lib/core/peers/bindings.mjs'") ||
|
|
2698
|
+
!hccSource.includes("import { createPeerBindingStore } from '../lib/db/stores/peers.mjs'") ||
|
|
2699
|
+
!hccSource.includes("} from '../lib/tmux.mjs'") ||
|
|
2700
|
+
!hccSource.includes("} from '../lib/core/coordination/locks.mjs'") ||
|
|
2701
|
+
!hccSource.includes("} from '../lib/core/coordination/teams.mjs'") ||
|
|
2702
|
+
!hccSource.includes("} from '../lib/integrations/peers/identity.mjs'") ||
|
|
2703
|
+
!hccSource.includes("} from '../lib/runtime/projects.mjs'") ||
|
|
2704
|
+
!hccSource.includes("} from '../lib/web/runtime.mjs'") ||
|
|
2705
|
+
!hccSource.includes("} from '../lib/web/http.mjs'") ||
|
|
2706
|
+
!hccSource.includes("import { webIndexHtml } from '../lib/web/ui-template.mjs'") ||
|
|
2239
2707
|
!hccSource.includes('const VERSION = PACKAGE_META.version') ||
|
|
2240
2708
|
!hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
|
|
2241
|
-
fail('CLI still has duplicated package metadata, cli args, DB schema helpers, format helpers, runtime paths, web runtime/HTTP/UI helpers, or guidance wiring');
|
|
2709
|
+
fail('CLI still has duplicated package metadata, cli args, DB schema helpers, CLI runtime helpers, coordination state helpers, format helpers, runtime paths/state helpers, runtime client helpers, project context helpers, handoff helpers, timeline helpers, task liveness helpers, automation helpers, state render helpers, help text helpers, message store helpers, task store helpers, task CLI helpers, session launch helpers, provider command helpers, peer binding helpers, tmux helpers, lock helpers, team planning helpers, peer identity helpers, project registry helpers, web runtime/HTTP/UI helpers, or guidance wiring');
|
|
2710
|
+
}
|
|
2711
|
+
for (const expected of [
|
|
2712
|
+
"renderAutomationContext",
|
|
2713
|
+
"from './core/coordination/automation.mjs'",
|
|
2714
|
+
"formatOpenTaskLine",
|
|
2715
|
+
"from './core/peers/liveness.mjs'",
|
|
2716
|
+
"timelineFromRows",
|
|
2717
|
+
"from './core/coordination/timeline.mjs'",
|
|
2718
|
+
"from './core/coordination/locks.mjs'"
|
|
2719
|
+
]) {
|
|
2720
|
+
if (!coordinationStateSource.includes(expected)) fail(`coordination state dependency missing: ${expected}`);
|
|
2242
2721
|
}
|
|
2243
2722
|
if (hccSource.includes('function createBaseSchema') ||
|
|
2244
2723
|
hccSource.includes('function runSchemaMigrations') ||
|
|
2245
2724
|
hccSource.includes('function readSchemaVersion')) {
|
|
2246
2725
|
fail('CLI still embeds DB schema or migration helpers');
|
|
2247
2726
|
}
|
|
2727
|
+
for (const helper of [
|
|
2728
|
+
'function createContext(',
|
|
2729
|
+
'function tailFile(',
|
|
2730
|
+
'function commandPath(',
|
|
2731
|
+
'function packageRoot('
|
|
2732
|
+
]) {
|
|
2733
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds CLI runtime helper: ${helper}`);
|
|
2734
|
+
}
|
|
2735
|
+
if (errorsCompatSource.includes('class CliError')) {
|
|
2736
|
+
fail('errors compatibility module still embeds CliError');
|
|
2737
|
+
}
|
|
2738
|
+
if (!errorsCompatSource.includes("from './shared/errors.mjs'") ||
|
|
2739
|
+
!sharedErrorsSource.includes('class CliError')) {
|
|
2740
|
+
fail('CliError is not wired through lib/shared/errors.mjs');
|
|
2741
|
+
}
|
|
2742
|
+
const oldErrorImportPattern = /from ['"](?:\.\.\/lib\/errors|\.\/errors|\.\.\/errors|\.\.\/\.\.\/errors)\.mjs['"]/;
|
|
2743
|
+
for (const file of ['bin/hcc.mjs', ...libModuleFiles()]) {
|
|
2744
|
+
const source = fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
|
2745
|
+
if (file !== 'lib/errors.mjs' && oldErrorImportPattern.test(source)) {
|
|
2746
|
+
fail(`${file} still imports CliError through the old errors module`);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
const sharedErrors = await import(path.join(repoRoot, 'lib', 'shared', 'errors.mjs'));
|
|
2750
|
+
const compatErrors = await import(path.join(repoRoot, 'lib', 'errors.mjs'));
|
|
2751
|
+
if (typeof sharedErrors.CliError !== 'function') fail('shared errors module missing CliError');
|
|
2752
|
+
if (compatErrors.CliError !== sharedErrors.CliError) fail('errors compatibility export mismatch');
|
|
2753
|
+
if (!hccSource.includes('function shellCommand(args)') ||
|
|
2754
|
+
hccSource.includes('return args.map(shellQuoteArg).join')) {
|
|
2755
|
+
fail('CLI shellCommand wrapper no longer delegates to cli-runtime shellCommand helper');
|
|
2756
|
+
}
|
|
2757
|
+
for (const helper of [
|
|
2758
|
+
'const CLAUDE_SETTINGS_PATH',
|
|
2759
|
+
'const CODEX_HOOKS_PATH',
|
|
2760
|
+
'function installClaudeHooks',
|
|
2761
|
+
'function uninstallClaudeHooks',
|
|
2762
|
+
'function verifyClaudeHooks',
|
|
2763
|
+
'function installCodexHooks',
|
|
2764
|
+
'function uninstallCodexHooks',
|
|
2765
|
+
'function verifyCodexHooks',
|
|
2766
|
+
'function mergeCodexHookEntry',
|
|
2767
|
+
'function mergeHookEntry',
|
|
2768
|
+
'function isHccHookCmd'
|
|
2769
|
+
]) {
|
|
2770
|
+
if (setupSource.includes(helper)) fail(`setup module still embeds hook helper: ${helper}`);
|
|
2771
|
+
}
|
|
2772
|
+
if (!setupSource.includes("from './integrations/hooks.mjs'")) {
|
|
2773
|
+
fail('setup module does not re-export hook helpers from integrations/hooks.mjs');
|
|
2774
|
+
}
|
|
2775
|
+
for (const helper of [
|
|
2776
|
+
'const SHIM_DIR',
|
|
2777
|
+
'function installShims',
|
|
2778
|
+
'function uninstallShims',
|
|
2779
|
+
'function verifyShims',
|
|
2780
|
+
'function findRealBinary',
|
|
2781
|
+
'function fsExists'
|
|
2782
|
+
]) {
|
|
2783
|
+
if (setupSource.includes(helper)) fail(`setup module still embeds shim helper: ${helper}`);
|
|
2784
|
+
}
|
|
2785
|
+
if (!setupSource.includes("from './integrations/shims.mjs'")) {
|
|
2786
|
+
fail('setup module does not re-export shim helpers from integrations/shims.mjs');
|
|
2787
|
+
}
|
|
2788
|
+
if (!setupSource.includes("from './shell-path.mjs'")) {
|
|
2789
|
+
fail('setup module does not re-export shell path helpers from shell-path.mjs');
|
|
2790
|
+
}
|
|
2791
|
+
if (!integrationHooksSource.includes("from '../shared/json-file.mjs'") ||
|
|
2792
|
+
!integrationHooksSource.includes("const CLAUDE_SETTINGS_PATH") ||
|
|
2793
|
+
!integrationHooksSource.includes("const CODEX_HOOKS_PATH")) {
|
|
2794
|
+
fail('integrations hook module is missing expected hook storage wiring');
|
|
2795
|
+
}
|
|
2796
|
+
if (!integrationShimsSource.includes("from './shims/script.mjs'") ||
|
|
2797
|
+
!integrationShimsSource.includes("const SHIM_DIR") ||
|
|
2798
|
+
!integrationShimsSource.includes('function findRealBinary') ||
|
|
2799
|
+
!integrationShimsSource.includes("spawnSync('which'")) {
|
|
2800
|
+
fail('integrations shim module is missing expected shim wiring');
|
|
2801
|
+
}
|
|
2802
|
+
if (!shimScriptCompatSource.includes("from './integrations/shims/script.mjs'")) {
|
|
2803
|
+
fail('shim script compatibility module does not re-export from integrations/shims/script.mjs');
|
|
2804
|
+
}
|
|
2805
|
+
if (!integrationShimScriptSource.includes('function generateShim') ||
|
|
2806
|
+
!integrationShimScriptSource.includes('hello-cc shim for ${tool.name}') ||
|
|
2807
|
+
!integrationShimScriptSource.includes('HCC_SHIM_ENSURED')) {
|
|
2808
|
+
fail('integrations shim script module is missing expected generateShim wiring');
|
|
2809
|
+
}
|
|
2810
|
+
if (!shellPathSource.includes('function installPathEntry') ||
|
|
2811
|
+
!shellPathSource.includes('function uninstallPathEntry') ||
|
|
2812
|
+
!shellPathSource.includes('.hcc-shims')) {
|
|
2813
|
+
fail('shell path module is missing expected setup path helpers');
|
|
2814
|
+
}
|
|
2815
|
+
const setupModule = await import(path.join(repoRoot, 'lib', 'setup.mjs'));
|
|
2816
|
+
const integrationHooks = await import(path.join(repoRoot, 'lib', 'integrations', 'hooks.mjs'));
|
|
2817
|
+
const integrationShims = await import(path.join(repoRoot, 'lib', 'integrations', 'shims.mjs'));
|
|
2818
|
+
const integrationShimScript = await import(path.join(repoRoot, 'lib', 'integrations', 'shims', 'script.mjs'));
|
|
2819
|
+
const shimScriptCompat = await import(path.join(repoRoot, 'lib', 'shim-script.mjs'));
|
|
2820
|
+
const shellPath = await import(path.join(repoRoot, 'lib', 'shell-path.mjs'));
|
|
2821
|
+
for (const name of [
|
|
2822
|
+
'installClaudeHooks',
|
|
2823
|
+
'uninstallClaudeHooks',
|
|
2824
|
+
'verifyClaudeHooks',
|
|
2825
|
+
'installCodexHooks',
|
|
2826
|
+
'uninstallCodexHooks',
|
|
2827
|
+
'verifyCodexHooks'
|
|
2828
|
+
]) {
|
|
2829
|
+
if (typeof integrationHooks[name] !== 'function') fail(`integrations hook module missing export: ${name}`);
|
|
2830
|
+
if (setupModule[name] !== integrationHooks[name]) fail(`setup hook export mismatch: ${name}`);
|
|
2831
|
+
}
|
|
2832
|
+
for (const name of ['installShims', 'uninstallShims', 'verifyShims', 'findRealBinary']) {
|
|
2833
|
+
if (typeof integrationShims[name] !== 'function') fail(`integrations shim module missing export: ${name}`);
|
|
2834
|
+
if (setupModule[name] !== integrationShims[name]) fail(`setup shim export mismatch: ${name}`);
|
|
2835
|
+
}
|
|
2836
|
+
if (typeof integrationShims.SHIM_DIR !== 'string' || !integrationShims.SHIM_DIR.endsWith('.hcc-shims')) {
|
|
2837
|
+
fail(`integrations shim module has unexpected SHIM_DIR: ${integrationShims.SHIM_DIR}`);
|
|
2838
|
+
}
|
|
2839
|
+
if (setupModule.SHIM_DIR !== integrationShims.SHIM_DIR) fail('setup shim dir export mismatch');
|
|
2840
|
+
if (typeof integrationShimScript.generateShim !== 'function') fail('integrations shim script module missing generateShim export');
|
|
2841
|
+
if (shimScriptCompat.generateShim !== integrationShimScript.generateShim) fail('shim script compatibility export mismatch');
|
|
2842
|
+
for (const name of ['installPathEntry', 'uninstallPathEntry']) {
|
|
2843
|
+
if (typeof shellPath[name] !== 'function') fail(`shell path module missing export: ${name}`);
|
|
2844
|
+
if (setupModule[name] !== shellPath[name]) fail(`setup shell path export mismatch: ${name}`);
|
|
2845
|
+
}
|
|
2846
|
+
const cliRuntime = await import(path.join(repoRoot, 'lib', 'cli-runtime.mjs'));
|
|
2847
|
+
for (const name of ['commandPath', 'createContext', 'packageRoot', 'shellCommand', 'tailFile']) {
|
|
2848
|
+
if (typeof cliRuntime[name] !== 'function') fail(`CLI runtime module missing function: ${name}`);
|
|
2849
|
+
}
|
|
2850
|
+
const runtimeCtx = cliRuntime.createContext(
|
|
2851
|
+
{ root: '/tmp/project', db: '.hello-cc/test.db', json: true },
|
|
2852
|
+
{ cwd: '/tmp/project/subdir', detectRoot: (_cwd, rootHint) => rootHint || '/tmp/project' }
|
|
2853
|
+
);
|
|
2854
|
+
if (runtimeCtx.cwd !== '/tmp/project/subdir' ||
|
|
2855
|
+
runtimeCtx.root !== '/tmp/project' ||
|
|
2856
|
+
!runtimeCtx.dbPath.endsWith('/.hello-cc/test.db') ||
|
|
2857
|
+
runtimeCtx.json !== true ||
|
|
2858
|
+
runtimeCtx.explicitRoot !== true) {
|
|
2859
|
+
fail(`CLI runtime createContext changed: ${JSON.stringify(runtimeCtx)}`);
|
|
2860
|
+
}
|
|
2861
|
+
const quotedCommand = cliRuntime.shellCommand(['alpha', 'two words'], (value) => `[${value}]`);
|
|
2862
|
+
if (quotedCommand !== '[alpha] [two words]') fail(`CLI runtime shellCommand changed: ${quotedCommand}`);
|
|
2863
|
+
const tailPath = path.join(outDir, 'tail-smoke.txt');
|
|
2864
|
+
fs.writeFileSync(tailPath, '0123456789\n');
|
|
2865
|
+
if (cliRuntime.tailFile(tailPath, 4) !== '789' || cliRuntime.tailFile(path.join(outDir, 'missing-tail')) !== '') {
|
|
2866
|
+
fail('CLI runtime tailFile behavior changed');
|
|
2867
|
+
}
|
|
2868
|
+
for (const helper of [
|
|
2869
|
+
'function collectStateSnapshot(',
|
|
2870
|
+
'function buildHookCoordinationContext(',
|
|
2871
|
+
'function ackMessages(',
|
|
2872
|
+
'function statusSummary(',
|
|
2873
|
+
'function statusSnapshot('
|
|
2874
|
+
]) {
|
|
2875
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds coordination state helper: ${helper}`);
|
|
2876
|
+
}
|
|
2877
|
+
const coordinationState = await import(path.join(repoRoot, 'lib', 'coordination-state.mjs'));
|
|
2878
|
+
if (typeof coordinationState.createCoordinationState !== 'function') {
|
|
2879
|
+
fail('coordination state module missing createCoordinationState');
|
|
2880
|
+
}
|
|
2881
|
+
const stateHelpers = coordinationState.createCoordinationState({
|
|
2882
|
+
connect: () => ({ close() {} }),
|
|
2883
|
+
queryInbox: () => [],
|
|
2884
|
+
queryOpenTasks: () => [],
|
|
2885
|
+
queryTimelineMessages: () => []
|
|
2886
|
+
});
|
|
2887
|
+
for (const name of ['ackMessages', 'buildHookCoordinationContext', 'collectStateSnapshot', 'statusSnapshot', 'statusSummary']) {
|
|
2888
|
+
if (typeof stateHelpers[name] !== 'function') fail(`coordination state factory missing function: ${name}`);
|
|
2889
|
+
}
|
|
2890
|
+
for (const helper of [
|
|
2891
|
+
'function readGlobalRuntimeFile',
|
|
2892
|
+
'function writeGlobalRuntime',
|
|
2893
|
+
'function writeRuntime',
|
|
2894
|
+
'function readRuntime',
|
|
2895
|
+
'function readRuntimeFile',
|
|
2896
|
+
'function probeRuntime',
|
|
2897
|
+
'function readHealthyRuntime',
|
|
2898
|
+
'function readHealthyGlobalRuntime',
|
|
2899
|
+
'function clearRuntime'
|
|
2900
|
+
]) {
|
|
2901
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds runtime state helper: ${helper}`);
|
|
2902
|
+
}
|
|
2903
|
+
const runtimePaths = await import(path.join(repoRoot, 'lib', 'runtime', 'paths.mjs'));
|
|
2904
|
+
const compatRuntimePaths = await import(path.join(repoRoot, 'lib', 'runtime-paths.mjs'));
|
|
2905
|
+
for (const [moduleName, mod] of [
|
|
2906
|
+
['runtime/paths', runtimePaths],
|
|
2907
|
+
['runtime-paths compat', compatRuntimePaths]
|
|
2908
|
+
]) {
|
|
2909
|
+
for (const name of [
|
|
2910
|
+
'contextForProject',
|
|
2911
|
+
'globalRuntimePath',
|
|
2912
|
+
'globalStateDir',
|
|
2913
|
+
'globalWebTokenPath',
|
|
2914
|
+
'projectDbPath',
|
|
2915
|
+
'projectRegistryPath',
|
|
2916
|
+
'projectStateDir',
|
|
2917
|
+
'runtimePath',
|
|
2918
|
+
'webLogPath'
|
|
2919
|
+
]) {
|
|
2920
|
+
if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
const runtimeState = await import(path.join(repoRoot, 'lib', 'runtime', 'state.mjs'));
|
|
2924
|
+
const compatRuntimeState = await import(path.join(repoRoot, 'lib', 'runtime-state.mjs'));
|
|
2925
|
+
for (const name of [
|
|
2926
|
+
'readGlobalRuntimeFile',
|
|
2927
|
+
'writeGlobalRuntime',
|
|
2928
|
+
'writeRuntime',
|
|
2929
|
+
'readRuntime',
|
|
2930
|
+
'readRuntimeFile',
|
|
2931
|
+
'probeRuntime',
|
|
2932
|
+
'readHealthyRuntime',
|
|
2933
|
+
'readHealthyGlobalRuntime',
|
|
2934
|
+
'clearRuntime'
|
|
2935
|
+
]) {
|
|
2936
|
+
if (typeof runtimeState[name] !== 'function') fail(`runtime state module missing export: ${name}`);
|
|
2937
|
+
if (typeof compatRuntimeState[name] !== 'function') fail(`runtime state compat module missing export: ${name}`);
|
|
2938
|
+
}
|
|
2939
|
+
const savedRuntimeEnv = {
|
|
2940
|
+
HOME: process.env.HOME,
|
|
2941
|
+
HCC_RUNTIME_URL: process.env.HCC_RUNTIME_URL,
|
|
2942
|
+
HCC_RUNTIME_TOKEN: process.env.HCC_RUNTIME_TOKEN
|
|
2943
|
+
};
|
|
2944
|
+
try {
|
|
2945
|
+
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-runtime-state-home-'));
|
|
2946
|
+
delete process.env.HCC_RUNTIME_URL;
|
|
2947
|
+
delete process.env.HCC_RUNTIME_TOKEN;
|
|
2948
|
+
const runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-runtime-state-root-'));
|
|
2949
|
+
const runtimeCtx = { root: runtimeRoot };
|
|
2950
|
+
|
|
2951
|
+
const projectRuntimeFile = runtimeState.writeRuntime(runtimeCtx, {
|
|
2952
|
+
base_url: 'http://127.0.0.1:11',
|
|
2953
|
+
token: 'project-token',
|
|
2954
|
+
pid: 101
|
|
2955
|
+
});
|
|
2956
|
+
if (!fs.existsSync(projectRuntimeFile) || (fs.statSync(projectRuntimeFile).mode & 0o777) !== 0o600) {
|
|
2957
|
+
fail('runtime state writeRuntime did not create a 0600 runtime file');
|
|
2958
|
+
}
|
|
2959
|
+
const projectRuntime = runtimeState.readRuntime(runtimeCtx);
|
|
2960
|
+
if (projectRuntime.base_url !== 'http://127.0.0.1:11' ||
|
|
2961
|
+
projectRuntime.token !== 'project-token' ||
|
|
2962
|
+
projectRuntime.source !== projectRuntimeFile) {
|
|
2963
|
+
fail(`runtime state project read changed: ${JSON.stringify(projectRuntime)}`);
|
|
2964
|
+
}
|
|
2965
|
+
if (runtimeState.readRuntimeFile(runtimeCtx)?.token !== 'project-token') {
|
|
2966
|
+
fail('runtime state readRuntimeFile changed');
|
|
2967
|
+
}
|
|
2968
|
+
runtimeState.clearRuntime(runtimeCtx, 202);
|
|
2969
|
+
if (!fs.existsSync(projectRuntimeFile)) fail('runtime state clearRuntime removed a different pid');
|
|
2970
|
+
runtimeState.clearRuntime(runtimeCtx, 101);
|
|
2971
|
+
if (fs.existsSync(projectRuntimeFile)) fail('runtime state clearRuntime did not remove matching project pid');
|
|
2972
|
+
|
|
2973
|
+
const globalRuntimeFile = runtimeState.writeGlobalRuntime({
|
|
2974
|
+
base_url: 'http://127.0.0.1:12',
|
|
2975
|
+
token: 'global-token',
|
|
2976
|
+
pid: 303
|
|
2977
|
+
});
|
|
2978
|
+
const globalRuntime = runtimeState.readRuntime(runtimeCtx);
|
|
2979
|
+
if (globalRuntime.base_url !== 'http://127.0.0.1:12' ||
|
|
2980
|
+
globalRuntime.token !== 'global-token' ||
|
|
2981
|
+
globalRuntime.source !== globalRuntimeFile ||
|
|
2982
|
+
globalRuntime.global !== true) {
|
|
2983
|
+
fail(`runtime state global fallback changed: ${JSON.stringify(globalRuntime)}`);
|
|
2984
|
+
}
|
|
2985
|
+
runtimeState.clearRuntime(runtimeCtx, 404);
|
|
2986
|
+
if (!fs.existsSync(globalRuntimeFile)) fail('runtime state clearRuntime removed a different global pid');
|
|
2987
|
+
runtimeState.clearRuntime(runtimeCtx, 303);
|
|
2988
|
+
if (fs.existsSync(globalRuntimeFile)) fail('runtime state clearRuntime did not remove matching global pid');
|
|
2989
|
+
|
|
2990
|
+
fs.writeFileSync(globalRuntimeFile, '{bad');
|
|
2991
|
+
if (runtimeState.readGlobalRuntimeFile() !== null || fs.existsSync(globalRuntimeFile)) {
|
|
2992
|
+
fail('runtime state readGlobalRuntimeFile did not remove invalid JSON');
|
|
2993
|
+
}
|
|
2994
|
+
|
|
2995
|
+
process.env.HCC_RUNTIME_URL = 'http://env-runtime.test';
|
|
2996
|
+
process.env.HCC_RUNTIME_TOKEN = 'env-token';
|
|
2997
|
+
const envRuntime = runtimeState.readRuntime(runtimeCtx);
|
|
2998
|
+
if (envRuntime.base_url !== 'http://env-runtime.test' ||
|
|
2999
|
+
envRuntime.token !== 'env-token' ||
|
|
3000
|
+
envRuntime.source !== 'env') {
|
|
3001
|
+
fail(`runtime state env runtime precedence changed: ${JSON.stringify(envRuntime)}`);
|
|
3002
|
+
}
|
|
3003
|
+
delete process.env.HCC_RUNTIME_URL;
|
|
3004
|
+
delete process.env.HCC_RUNTIME_TOKEN;
|
|
3005
|
+
|
|
3006
|
+
try {
|
|
3007
|
+
runtimeState.readRuntime(runtimeCtx, { productName: 'product-x', cliName: 'cli-x' });
|
|
3008
|
+
fail('runtime state readRuntime succeeded without runtime');
|
|
3009
|
+
} catch (err) {
|
|
3010
|
+
if (err?.code !== 'RUNTIME_NOT_RUNNING' ||
|
|
3011
|
+
!String(err.message || '').includes('product-x') ||
|
|
3012
|
+
!String(err.message || '').includes('cli-x web')) {
|
|
3013
|
+
throw err;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
if (await runtimeState.probeRuntime(null) !== false ||
|
|
3017
|
+
await runtimeState.readHealthyRuntime(runtimeCtx) !== null ||
|
|
3018
|
+
await runtimeState.readHealthyGlobalRuntime() !== null) {
|
|
3019
|
+
fail('runtime state unhealthy runtime behavior changed');
|
|
3020
|
+
}
|
|
3021
|
+
} finally {
|
|
3022
|
+
for (const [key, value] of Object.entries(savedRuntimeEnv)) {
|
|
3023
|
+
if (value === undefined) delete process.env[key];
|
|
3024
|
+
else process.env[key] = value;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
if (hccSource.includes('async function runtimeRequest')) fail('CLI still embeds runtime request client helper');
|
|
3028
|
+
const runtimeClient = await import(path.join(repoRoot, 'lib', 'runtime', 'client.mjs'));
|
|
3029
|
+
const compatRuntimeClient = await import(path.join(repoRoot, 'lib', 'runtime-client.mjs'));
|
|
3030
|
+
if (typeof runtimeClient.runtimeRequest !== 'function') fail('runtime client module missing runtimeRequest export');
|
|
3031
|
+
if (typeof compatRuntimeClient.runtimeRequest !== 'function') fail('runtime client compat module missing runtimeRequest export');
|
|
3032
|
+
const savedFetch = globalThis.fetch;
|
|
3033
|
+
const runtimeFetchCalls = [];
|
|
3034
|
+
try {
|
|
3035
|
+
globalThis.fetch = async (url, opts = {}) => {
|
|
3036
|
+
runtimeFetchCalls.push({ url, opts });
|
|
3037
|
+
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
3038
|
+
};
|
|
3039
|
+
const runtimeClientData = await runtimeClient.runtimeRequest(
|
|
3040
|
+
{ root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
|
|
3041
|
+
'POST',
|
|
3042
|
+
'/api/test',
|
|
3043
|
+
{ value: 1 },
|
|
3044
|
+
{ base_url: 'http://127.0.0.1:8787/', token: 'runtime-token', source: 'runtime-file' },
|
|
3045
|
+
{ cliName: 'hccx' }
|
|
3046
|
+
);
|
|
3047
|
+
if (runtimeClientData.ok !== true || runtimeFetchCalls.length !== 1) {
|
|
3048
|
+
fail(`runtime client request data changed: ${JSON.stringify({ runtimeClientData, runtimeFetchCalls })}`);
|
|
3049
|
+
}
|
|
3050
|
+
const call = runtimeFetchCalls[0];
|
|
3051
|
+
if (String(call.url) !== 'http://127.0.0.1:8787/api/test' ||
|
|
3052
|
+
call.opts.method !== 'POST' ||
|
|
3053
|
+
call.opts.headers.Authorization !== 'Bearer runtime-token' ||
|
|
3054
|
+
call.opts.headers['X-HCC-Root'] !== '/repo-root' ||
|
|
3055
|
+
call.opts.headers['X-HCC-DB'] !== '/repo-root/.hello-cc/mesh.db' ||
|
|
3056
|
+
call.opts.body !== JSON.stringify({ value: 1 })) {
|
|
3057
|
+
fail(`runtime client request shape changed: ${JSON.stringify(runtimeFetchCalls, null, 2)}`);
|
|
3058
|
+
}
|
|
3059
|
+
globalThis.fetch = async () => new Response('not-json', { status: 200 });
|
|
3060
|
+
try {
|
|
3061
|
+
await runtimeClient.runtimeRequest(
|
|
3062
|
+
{ root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
|
|
3063
|
+
'GET',
|
|
3064
|
+
'/api/bad',
|
|
3065
|
+
null,
|
|
3066
|
+
{ base_url: 'http://127.0.0.1:8787/' }
|
|
3067
|
+
);
|
|
3068
|
+
fail('runtime client accepted non-JSON runtime response');
|
|
3069
|
+
} catch (err) {
|
|
3070
|
+
if (err?.code !== 'RUNTIME_BAD_RESPONSE') throw err;
|
|
3071
|
+
}
|
|
3072
|
+
globalThis.fetch = async () => {
|
|
3073
|
+
throw new Error('offline');
|
|
3074
|
+
};
|
|
3075
|
+
try {
|
|
3076
|
+
await runtimeClient.runtimeRequest(
|
|
3077
|
+
{ root: '/repo-root', dbPath: '/repo-root/.hello-cc/mesh.db' },
|
|
3078
|
+
'GET',
|
|
3079
|
+
'/api/offline',
|
|
3080
|
+
null,
|
|
3081
|
+
{ base_url: 'http://127.0.0.1:8787/', source: 'runtime-file' },
|
|
3082
|
+
{ cliName: 'hccx' }
|
|
3083
|
+
);
|
|
3084
|
+
fail('runtime client accepted unreachable runtime');
|
|
3085
|
+
} catch (err) {
|
|
3086
|
+
if (err?.code !== 'RUNTIME_UNREACHABLE' ||
|
|
3087
|
+
!String(err.message || '').includes('Start hccx web again') ||
|
|
3088
|
+
err.extra?.runtime !== 'runtime-file') {
|
|
3089
|
+
throw err;
|
|
3090
|
+
}
|
|
3091
|
+
}
|
|
3092
|
+
} finally {
|
|
3093
|
+
globalThis.fetch = savedFetch;
|
|
3094
|
+
}
|
|
3095
|
+
for (const helper of [
|
|
3096
|
+
'function runGit',
|
|
3097
|
+
'function hasHccRootSync',
|
|
3098
|
+
'function detectRoot',
|
|
3099
|
+
'function detectBranch'
|
|
3100
|
+
]) {
|
|
3101
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds project context helper: ${helper}`);
|
|
3102
|
+
}
|
|
3103
|
+
const projectContext = await import(path.join(repoRoot, 'lib', 'project-context.mjs'));
|
|
3104
|
+
for (const name of [
|
|
3105
|
+
'runGit',
|
|
3106
|
+
'hasHccRootSync',
|
|
3107
|
+
'detectRoot',
|
|
3108
|
+
'detectBranch'
|
|
3109
|
+
]) {
|
|
3110
|
+
if (typeof projectContext[name] !== 'function') fail(`project context module missing export: ${name}`);
|
|
3111
|
+
}
|
|
3112
|
+
const gitTop = projectContext.runGit(['rev-parse', '--show-toplevel'], repoRoot);
|
|
3113
|
+
if (path.resolve(gitTop || '') !== repoRoot) fail(`project context runGit changed: ${gitTop}`);
|
|
3114
|
+
const gitBranch = projectContext.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot) || '';
|
|
3115
|
+
const detectedBranch = projectContext.detectBranch(repoRoot);
|
|
3116
|
+
if (detectedBranch !== gitBranch) {
|
|
3117
|
+
fail(`project context detectBranch changed: ${detectedBranch} != ${gitBranch}`);
|
|
3118
|
+
}
|
|
3119
|
+
const savedHccRoot = process.env.HCC_ROOT;
|
|
3120
|
+
try {
|
|
3121
|
+
delete process.env.HCC_ROOT;
|
|
3122
|
+
if (projectContext.detectRoot('/tmp/hcc-a', '') !== path.resolve('/tmp/hcc-a')) {
|
|
3123
|
+
fail('project context detectRoot cwd fallback changed');
|
|
3124
|
+
}
|
|
3125
|
+
process.env.HCC_ROOT = '/tmp/hcc-env-root';
|
|
3126
|
+
if (projectContext.detectRoot('/tmp/hcc-a', '') !== path.resolve('/tmp/hcc-env-root') ||
|
|
3127
|
+
projectContext.detectRoot('/tmp/hcc-a', '/tmp/hcc-explicit-root') !== path.resolve('/tmp/hcc-explicit-root')) {
|
|
3128
|
+
fail('project context detectRoot precedence changed');
|
|
3129
|
+
}
|
|
3130
|
+
} finally {
|
|
3131
|
+
if (savedHccRoot === undefined) delete process.env.HCC_ROOT;
|
|
3132
|
+
else process.env.HCC_ROOT = savedHccRoot;
|
|
3133
|
+
}
|
|
3134
|
+
const contextRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-project-context-'));
|
|
3135
|
+
fs.mkdirSync(path.join(contextRoot, '.hello-cc'), { recursive: true });
|
|
3136
|
+
if (projectContext.hasHccRootSync(contextRoot)) fail('project context detected root before marker file');
|
|
3137
|
+
fs.writeFileSync(path.join(contextRoot, '.hello-cc', 'config.json'), '{}');
|
|
3138
|
+
if (!projectContext.hasHccRootSync(contextRoot)) fail('project context did not detect config marker');
|
|
3139
|
+
for (const helper of [
|
|
3140
|
+
'function normalizeListText',
|
|
3141
|
+
'function changedFiles'
|
|
3142
|
+
]) {
|
|
3143
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds handoff helper: ${helper}`);
|
|
3144
|
+
}
|
|
3145
|
+
const handoffModule = await import(path.join(repoRoot, 'lib', 'handoff.mjs'));
|
|
3146
|
+
for (const name of [
|
|
3147
|
+
'normalizeListText',
|
|
3148
|
+
'changedFiles'
|
|
3149
|
+
]) {
|
|
3150
|
+
if (typeof handoffModule[name] !== 'function') fail(`handoff module missing export: ${name}`);
|
|
3151
|
+
}
|
|
3152
|
+
if (handoffModule.normalizeListText(undefined, ['fallback']) !== JSON.stringify(['fallback'])) {
|
|
3153
|
+
fail('handoff normalizeListText fallback changed');
|
|
3154
|
+
}
|
|
3155
|
+
if (handoffModule.normalizeListText('["kept"]') !== '["kept"]') {
|
|
3156
|
+
fail('handoff normalizeListText JSON passthrough changed');
|
|
3157
|
+
}
|
|
3158
|
+
if (handoffModule.normalizeListText('one, two,, three') !== JSON.stringify(['one', 'two', 'three'])) {
|
|
3159
|
+
fail('handoff normalizeListText CSV parsing changed');
|
|
3160
|
+
}
|
|
3161
|
+
const handoffGitRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hcc-handoff-git-'));
|
|
3162
|
+
run('git', ['init'], { cwd: handoffGitRoot });
|
|
3163
|
+
run('git', ['config', 'user.email', 'hcc-regression@example.invalid'], { cwd: handoffGitRoot });
|
|
3164
|
+
run('git', ['config', 'user.name', 'hcc regression'], { cwd: handoffGitRoot });
|
|
3165
|
+
fs.writeFileSync(path.join(handoffGitRoot, 'tracked.txt'), 'base\n');
|
|
3166
|
+
run('git', ['add', 'tracked.txt'], { cwd: handoffGitRoot });
|
|
3167
|
+
run('git', ['commit', '-m', 'init'], { cwd: handoffGitRoot });
|
|
3168
|
+
fs.writeFileSync(path.join(handoffGitRoot, 'tracked.txt'), 'changed\n');
|
|
3169
|
+
fs.writeFileSync(path.join(handoffGitRoot, 'staged.txt'), 'new\n');
|
|
3170
|
+
run('git', ['add', 'staged.txt'], { cwd: handoffGitRoot });
|
|
3171
|
+
const handoffChanged = handoffModule.changedFiles(handoffGitRoot);
|
|
3172
|
+
if (JSON.stringify(handoffChanged) !== JSON.stringify(['staged.txt', 'tracked.txt'])) {
|
|
3173
|
+
fail(`handoff changedFiles changed: ${JSON.stringify(handoffChanged)}`);
|
|
3174
|
+
}
|
|
3175
|
+
for (const helper of [
|
|
3176
|
+
'function parseEventPayload',
|
|
3177
|
+
'function uniqueList',
|
|
3178
|
+
'function messageParticipants',
|
|
3179
|
+
'function taskParticipants',
|
|
3180
|
+
'function payloadParticipants',
|
|
3181
|
+
'function peerMatchesTimelineItem',
|
|
3182
|
+
'function shouldHideTimelineMessage',
|
|
3183
|
+
'function shouldHideTimelineEvent',
|
|
3184
|
+
'function timelineDirection',
|
|
3185
|
+
'function timelineFromRows'
|
|
3186
|
+
]) {
|
|
3187
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds timeline helper: ${helper}`);
|
|
3188
|
+
}
|
|
3189
|
+
if (hccSource.includes('const TIMELINE_EVENT_ALLOW')) fail('CLI still embeds timeline event allow list');
|
|
3190
|
+
const timelineModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'timeline.mjs'));
|
|
3191
|
+
const compatTimelineModule = await import(path.join(repoRoot, 'lib', 'timeline.mjs'));
|
|
3192
|
+
for (const name of [
|
|
3193
|
+
'parseEventPayload',
|
|
3194
|
+
'uniqueList',
|
|
3195
|
+
'messageParticipants',
|
|
3196
|
+
'taskParticipants',
|
|
3197
|
+
'payloadParticipants',
|
|
3198
|
+
'peerMatchesTimelineItem',
|
|
3199
|
+
'shouldHideTimelineMessage',
|
|
3200
|
+
'shouldHideTimelineEvent',
|
|
3201
|
+
'timelineDirection',
|
|
3202
|
+
'timelineFromRows'
|
|
3203
|
+
]) {
|
|
3204
|
+
if (typeof timelineModule[name] !== 'function') fail(`timeline module missing export: ${name}`);
|
|
3205
|
+
if (typeof compatTimelineModule[name] !== 'function') fail(`timeline compat module missing export: ${name}`);
|
|
3206
|
+
}
|
|
3207
|
+
if (JSON.stringify(timelineModule.uniqueList(['a', '', null, 'a', 7])) !== JSON.stringify(['a', '7'])) {
|
|
3208
|
+
fail('timeline uniqueList behavior changed');
|
|
3209
|
+
}
|
|
3210
|
+
if (JSON.stringify(timelineModule.parseEventPayload({ payload: '{bad' })) !== JSON.stringify({})) {
|
|
3211
|
+
fail('timeline parseEventPayload invalid JSON behavior changed');
|
|
3212
|
+
}
|
|
3213
|
+
if (!timelineModule.shouldHideTimelineMessage({ kind: 'task', body: 'Task #12 assigned: do work' }) ||
|
|
3214
|
+
!timelineModule.shouldHideTimelineEvent({ type: 'message.sent' }) ||
|
|
3215
|
+
timelineModule.shouldHideTimelineEvent({ type: 'task.done' })) {
|
|
3216
|
+
fail('timeline hide filters changed');
|
|
3217
|
+
}
|
|
3218
|
+
const timelineItems = timelineModule.timelineFromRows({
|
|
3219
|
+
messages: [
|
|
3220
|
+
{ id: 1, sender: 'system', recipient: 'peer-b', task_id: 1, kind: 'task', body: 'Task #1 assigned: hidden', created_at: 1, read_at: null },
|
|
3221
|
+
{ id: 2, sender: 'peer-a', recipient: 'peer-b', task_id: 2, kind: 'note', body: 'hello timeline', created_at: 2, read_at: null },
|
|
3222
|
+
{ id: 3, sender: 'peer-b', recipient: 'all', task_id: null, kind: 'note', body: 'broadcast timeline', created_at: 3, read_at: 3 }
|
|
3223
|
+
],
|
|
3224
|
+
handoffs: [
|
|
3225
|
+
{ id: 4, from_peer: 'peer-a', to_peer: 'peer-b', task_id: 2, summary: 'handoff summary', created_at: 4, tests: 'tests', risks: 'risks' }
|
|
3226
|
+
],
|
|
3227
|
+
tasks: [
|
|
3228
|
+
{ id: 5, status: 'running', created_by: 'human', owner: 'peer-b', assignee: '', title: 'task title', created_at: 5, updated_at: 5, parent_id: null }
|
|
3229
|
+
],
|
|
3230
|
+
locks: [
|
|
3231
|
+
{ resource: 'bin/hcc.mjs', base_resource: 'bin/hcc.mjs', scope: 'timeline', owner: 'peer-a', task_id: 2, reason: 'hidden from peer-b', created_at: 6 }
|
|
3232
|
+
],
|
|
3233
|
+
events: [
|
|
3234
|
+
{ id: 7, type: 'message.sent', actor: 'peer-a', task_id: 2, payload: '{}', created_at: 7 },
|
|
3235
|
+
{ id: 8, type: 'task.done', actor: 'peer-a', task_id: 2, payload: JSON.stringify({ owner: 'peer-b', summary: 'done summary' }), created_at: 8 }
|
|
3236
|
+
]
|
|
3237
|
+
}, 'peer-b');
|
|
3238
|
+
const timelineIds = timelineItems.map((item) => item.id);
|
|
3239
|
+
if (JSON.stringify(timelineIds) !== JSON.stringify(['message:2', 'message:3', 'handoff:4', 'task:5', 'event:8'])) {
|
|
3240
|
+
fail(`timelineFromRows filtering/order changed: ${JSON.stringify(timelineItems)}`);
|
|
3241
|
+
}
|
|
3242
|
+
const inbound = timelineItems.find((item) => item.id === 'message:2');
|
|
3243
|
+
const broadcast = timelineItems.find((item) => item.id === 'message:3');
|
|
3244
|
+
if (inbound?.direction !== 'in' || inbound?.unread !== true ||
|
|
3245
|
+
broadcast?.direction !== 'out' || broadcast?.broadcast !== true) {
|
|
3246
|
+
fail(`timelineFromRows message metadata changed: ${JSON.stringify(timelineItems)}`);
|
|
3247
|
+
}
|
|
3248
|
+
for (const helper of [
|
|
3249
|
+
'function taskRelatedLocks',
|
|
3250
|
+
'function taskOwnerLiveness',
|
|
3251
|
+
'function annotateTasksWithLiveness',
|
|
3252
|
+
'function taskOwnerStateText',
|
|
3253
|
+
'function summarizeTask',
|
|
3254
|
+
'function formatOpenTaskLine'
|
|
3255
|
+
]) {
|
|
3256
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds task liveness helper: ${helper}`);
|
|
3257
|
+
}
|
|
3258
|
+
const taskLivenessModule = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'liveness.mjs'));
|
|
3259
|
+
const compatTaskLivenessModule = await import(path.join(repoRoot, 'lib', 'task-liveness.mjs'));
|
|
3260
|
+
for (const name of [
|
|
3261
|
+
'taskRelatedLocks',
|
|
3262
|
+
'taskOwnerLiveness',
|
|
3263
|
+
'annotateTasksWithLiveness',
|
|
3264
|
+
'taskOwnerStateText',
|
|
3265
|
+
'summarizeTask',
|
|
3266
|
+
'formatOpenTaskLine'
|
|
3267
|
+
]) {
|
|
3268
|
+
if (typeof taskLivenessModule[name] !== 'function') fail(`task liveness module missing export: ${name}`);
|
|
3269
|
+
if (typeof compatTaskLivenessModule[name] !== 'function') fail(`task liveness compat module missing export: ${name}`);
|
|
3270
|
+
}
|
|
3271
|
+
const livenessTasks = taskLivenessModule.annotateTasksWithLiveness([
|
|
3272
|
+
{ id: 1, status: 'running', owner: 'active-owner', assignee: '', title: 'Active task', priority: 1 },
|
|
3273
|
+
{ id: 2, status: 'running', owner: 'stale-owner', assignee: '', title: 'Stale task', priority: 2 },
|
|
3274
|
+
{ id: 3, status: 'running', owner: 'locked-owner', assignee: '', title: 'Locked task', priority: 3 },
|
|
3275
|
+
{ id: 4, status: 'pending', owner: 'stale-pending-owner', assignee: '', title: 'Pending stale task', priority: 4 },
|
|
3276
|
+
{ id: 5, status: 'pending', owner: '', assignee: 'worker', title: 'Unowned task', priority: 5 }
|
|
3277
|
+
], [
|
|
3278
|
+
{ id: 'active-owner', age_sec: 10 },
|
|
3279
|
+
{ id: 'stale-owner', last_seen_at: 100 },
|
|
3280
|
+
{ id: 'locked-owner', age_sec: 900 },
|
|
3281
|
+
{ id: 'stale-pending-owner', age_sec: 900 }
|
|
3282
|
+
], [
|
|
3283
|
+
{ owner: 'locked-owner', task_id: 3 }
|
|
3284
|
+
], 1000, 600);
|
|
3285
|
+
const activeTask = livenessTasks.find((task) => task.id === 1);
|
|
3286
|
+
const staleTask = livenessTasks.find((task) => task.id === 2);
|
|
3287
|
+
const lockedTask = livenessTasks.find((task) => task.id === 3);
|
|
3288
|
+
const pendingTask = livenessTasks.find((task) => task.id === 4);
|
|
3289
|
+
const unownedTask = livenessTasks.find((task) => task.id === 5);
|
|
3290
|
+
if (!activeTask.owner_active || activeTask.owner_stale || taskLivenessModule.taskOwnerStateText(activeTask) !== 'active') {
|
|
3291
|
+
fail(`task liveness active owner changed: ${JSON.stringify(activeTask)}`);
|
|
3292
|
+
}
|
|
3293
|
+
if (!staleTask.owner_stale || !staleTask.takeover_ready || staleTask.owner_age_sec !== 900 ||
|
|
3294
|
+
taskLivenessModule.taskOwnerStateText(staleTask) !== 'stale/no-lock') {
|
|
3295
|
+
fail(`task liveness stale takeover changed: ${JSON.stringify(staleTask)}`);
|
|
3296
|
+
}
|
|
3297
|
+
if (!lockedTask.owner_stale || lockedTask.takeover_ready || lockedTask.related_lock_count !== 1 ||
|
|
3298
|
+
taskLivenessModule.taskOwnerStateText(lockedTask) !== 'stale/locks=1') {
|
|
3299
|
+
fail(`task liveness stale locked owner changed: ${JSON.stringify(lockedTask)}`);
|
|
3300
|
+
}
|
|
3301
|
+
if (!pendingTask.owner_stale || pendingTask.takeover_ready) {
|
|
3302
|
+
fail(`task liveness pending takeover changed: ${JSON.stringify(pendingTask)}`);
|
|
3303
|
+
}
|
|
3304
|
+
if (unownedTask.owner_known || unownedTask.owner_active !== null || unownedTask.owner_stale || unownedTask.takeover_ready) {
|
|
3305
|
+
fail(`task liveness unowned task changed: ${JSON.stringify(unownedTask)}`);
|
|
3306
|
+
}
|
|
3307
|
+
const summary = taskLivenessModule.summarizeTask(staleTask);
|
|
3308
|
+
if (!summary.takeover_ready || summary.owner_age_sec !== 900 || summary.related_lock_count !== 0) {
|
|
3309
|
+
fail(`task liveness summarizeTask changed: ${JSON.stringify(summary)}`);
|
|
3310
|
+
}
|
|
3311
|
+
if (taskLivenessModule.formatOpenTaskLine(staleTask) !== '#2 running owner=stale-owner owner_state=stale/no-lock: Stale task') {
|
|
3312
|
+
fail(`task liveness formatOpenTaskLine changed: ${taskLivenessModule.formatOpenTaskLine(staleTask)}`);
|
|
3313
|
+
}
|
|
3314
|
+
for (const helper of [
|
|
3315
|
+
'function actionCommand',
|
|
3316
|
+
'function makeAction',
|
|
3317
|
+
'function looksLikeMultiTask',
|
|
3318
|
+
'function selectCurrentTask',
|
|
3319
|
+
'function deriveAutomation',
|
|
3320
|
+
'function renderAutomationContext'
|
|
3321
|
+
]) {
|
|
3322
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds automation helper: ${helper}`);
|
|
3323
|
+
}
|
|
3324
|
+
const automationModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'automation.mjs'));
|
|
3325
|
+
const compatAutomationModule = await import(path.join(repoRoot, 'lib', 'automation.mjs'));
|
|
3326
|
+
for (const name of [
|
|
3327
|
+
'actionCommand',
|
|
3328
|
+
'makeAction',
|
|
3329
|
+
'looksLikeMultiTask',
|
|
3330
|
+
'selectCurrentTask',
|
|
3331
|
+
'deriveAutomation',
|
|
3332
|
+
'renderAutomationContext'
|
|
3333
|
+
]) {
|
|
3334
|
+
if (typeof automationModule[name] !== 'function') fail(`automation module missing export: ${name}`);
|
|
3335
|
+
if (typeof compatAutomationModule[name] !== 'function') fail(`automation compat module missing export: ${name}`);
|
|
3336
|
+
}
|
|
3337
|
+
if (!automationModule.looksLikeMultiTask({ title: 'split', body: '- one\n- two' })) {
|
|
3338
|
+
fail('automation looksLikeMultiTask bullet detection changed');
|
|
3339
|
+
}
|
|
3340
|
+
const selectedTask = automationModule.selectCurrentTask([
|
|
3341
|
+
{ id: 2, status: 'claimed', owner: 'peer-a', priority: 1 },
|
|
3342
|
+
{ id: 1, status: 'running', owner: 'peer-a', priority: 9 }
|
|
3343
|
+
], 'peer-a');
|
|
3344
|
+
if (selectedTask?.id !== 1) fail(`automation selectCurrentTask ranking changed: ${JSON.stringify(selectedTask)}`);
|
|
3345
|
+
const automationConfig = { cliName: 'hccx', activePeerTtl: 600, defaultLockTtl: 321 };
|
|
3346
|
+
const automationSnapshot = {
|
|
3347
|
+
now: 1000,
|
|
3348
|
+
active_peer_ttl: 600,
|
|
3349
|
+
peers: [
|
|
3350
|
+
{ id: 'peer-a', age_sec: 10 },
|
|
3351
|
+
{ id: 'other-peer', age_sec: 20 }
|
|
3352
|
+
],
|
|
3353
|
+
tasks: [
|
|
3354
|
+
{ id: 10, status: 'running', owner: 'peer-a', assignee: '', title: 'Main task', priority: 1, parent_id: null }
|
|
3355
|
+
],
|
|
3356
|
+
locks: [],
|
|
3357
|
+
messages: []
|
|
3358
|
+
};
|
|
3359
|
+
const acquireAutomation = automationModule.deriveAutomation(
|
|
3360
|
+
automationSnapshot,
|
|
3361
|
+
'peer-a',
|
|
3362
|
+
{ resource: 'bin/hcc.mjs', scope: 'automation' },
|
|
3363
|
+
automationConfig
|
|
3364
|
+
);
|
|
3365
|
+
if (acquireAutomation.phase !== 'acquire_lock' ||
|
|
3366
|
+
acquireAutomation.next_action.kind !== 'lock.acquire' ||
|
|
3367
|
+
!acquireAutomation.next_action.argv.includes('321') ||
|
|
3368
|
+
!String(acquireAutomation.next_action.command || '').startsWith('hccx lock acquire')) {
|
|
3369
|
+
fail(`automation lock acquire action changed: ${JSON.stringify(acquireAutomation, null, 2)}`);
|
|
3370
|
+
}
|
|
3371
|
+
const finishAutomation = automationModule.deriveAutomation(
|
|
3372
|
+
automationSnapshot,
|
|
3373
|
+
'peer-a',
|
|
3374
|
+
{ intent: 'finish' },
|
|
3375
|
+
automationConfig
|
|
3376
|
+
);
|
|
3377
|
+
if (finishAutomation.phase !== 'handoff' || finishAutomation.next_action.kind !== 'handoff.create') {
|
|
3378
|
+
fail(`automation finish action changed: ${JSON.stringify(finishAutomation, null, 2)}`);
|
|
3379
|
+
}
|
|
3380
|
+
const claimAutomation = automationModule.deriveAutomation({
|
|
3381
|
+
...automationSnapshot,
|
|
3382
|
+
tasks: [
|
|
3383
|
+
{ id: 11, status: 'pending', owner: '', assignee: 'peer-a', title: 'Assigned task', priority: 1 }
|
|
3384
|
+
]
|
|
3385
|
+
}, 'peer-a', {}, automationConfig);
|
|
3386
|
+
if (claimAutomation.phase !== 'claim_task' ||
|
|
3387
|
+
claimAutomation.next_action.kind !== 'task.claim' ||
|
|
3388
|
+
claimAutomation.next_action.task_id !== 11) {
|
|
3389
|
+
fail(`automation assigned claim action changed: ${JSON.stringify(claimAutomation, null, 2)}`);
|
|
3390
|
+
}
|
|
3391
|
+
const conflictAutomation = automationModule.deriveAutomation({
|
|
3392
|
+
...automationSnapshot,
|
|
3393
|
+
locks: [
|
|
3394
|
+
{ resource: 'scoped:runtime', base_resource: 'bin/hcc.mjs', scope: 'automation', owner: 'other-peer', task_id: 99, expires_at: 1200 }
|
|
3395
|
+
]
|
|
3396
|
+
}, 'peer-a', { resource: 'bin/hcc.mjs', scope: 'automation' }, automationConfig);
|
|
3397
|
+
if (conflictAutomation.phase !== 'coordinate_lock' ||
|
|
3398
|
+
conflictAutomation.next_action.kind !== 'msg.send' ||
|
|
3399
|
+
conflictAutomation.next_action.lock_owner !== 'other-peer') {
|
|
3400
|
+
fail(`automation lock conflict action changed: ${JSON.stringify(conflictAutomation, null, 2)}`);
|
|
3401
|
+
}
|
|
3402
|
+
const automationContext = automationModule.renderAutomationContext(acquireAutomation);
|
|
3403
|
+
if (!automationContext.includes('phase: acquire_lock') ||
|
|
3404
|
+
!automationContext.includes('why: task #10 needs bin/hcc.mjs [automation] lock')) {
|
|
3405
|
+
fail(`automation render context changed: ${automationContext}`);
|
|
3406
|
+
}
|
|
3407
|
+
for (const helper of [
|
|
3408
|
+
'function renderStatusSummary',
|
|
3409
|
+
'function normalizeStateResources',
|
|
3410
|
+
'function renderStateSummary'
|
|
3411
|
+
]) {
|
|
3412
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds state render helper: ${helper}`);
|
|
3413
|
+
}
|
|
3414
|
+
const stateRender = await import(path.join(repoRoot, 'lib', 'ui', 'state-render.mjs'));
|
|
3415
|
+
const compatStateRender = await import(path.join(repoRoot, 'lib', 'state-render.mjs'));
|
|
3416
|
+
for (const name of [
|
|
3417
|
+
'renderStatusSummary',
|
|
3418
|
+
'normalizeStateResources',
|
|
3419
|
+
'renderStateSummary'
|
|
3420
|
+
]) {
|
|
3421
|
+
if (typeof stateRender[name] !== 'function') fail(`state render module missing export: ${name}`);
|
|
3422
|
+
if (typeof compatStateRender[name] !== 'function') fail(`state render compat module missing export: ${name}`);
|
|
3423
|
+
}
|
|
3424
|
+
const normalizedResources = stateRender.normalizeStateResources(['bin/hcc.mjs,scripts/regression.mjs', 'bin/hcc.mjs', '', null]);
|
|
3425
|
+
if (JSON.stringify(normalizedResources) !== JSON.stringify(['bin/hcc.mjs', 'scripts/regression.mjs'])) {
|
|
3426
|
+
fail(`state resource normalization changed: ${JSON.stringify(normalizedResources)}`);
|
|
3427
|
+
}
|
|
3428
|
+
const renderedStatus = stateRender.renderStatusSummary({
|
|
3429
|
+
root: '/repo',
|
|
3430
|
+
db: '/repo/.hello-cc/mesh.db',
|
|
3431
|
+
active_peers: 2,
|
|
3432
|
+
stale_peers: 3,
|
|
3433
|
+
tasks: [
|
|
3434
|
+
{ status: 'done', n: 4 },
|
|
3435
|
+
{ status: 'running', n: 1 }
|
|
3436
|
+
],
|
|
3437
|
+
active_locks: 5,
|
|
3438
|
+
unread: 6,
|
|
3439
|
+
recent_events: [
|
|
3440
|
+
{ id: 7, type: 'task.done', actor: 'peer-a', task_id: 8, created_at: 9 }
|
|
3441
|
+
]
|
|
3442
|
+
}, 'peer-a');
|
|
3443
|
+
if (!renderedStatus.includes('peers: active=2, stale=3') ||
|
|
3444
|
+
!renderedStatus.includes('tasks: done:4, running:1') ||
|
|
3445
|
+
!renderedStatus.includes('inbox(peer-a): unread=6') ||
|
|
3446
|
+
!renderedStatus.includes('1970-01-01T00:00:09.000Z')) {
|
|
3447
|
+
fail(`state render status output changed:\n${renderedStatus}`);
|
|
3448
|
+
}
|
|
3449
|
+
const renderedState = stateRender.renderStateSummary({
|
|
3450
|
+
root: '/repo',
|
|
3451
|
+
automation: {
|
|
3452
|
+
current_task: { id: 10, status: 'running', title: 'State task' },
|
|
3453
|
+
phase: 'work',
|
|
3454
|
+
next_action: { kind: 'none', command: '', reason: 'continue task #10' },
|
|
3455
|
+
finish_actions: [{ command: 'hcc handoff create' }],
|
|
3456
|
+
warnings: ['review locks before commit']
|
|
3457
|
+
},
|
|
3458
|
+
timeline: [
|
|
3459
|
+
{ ts: 11, source: 'message', source_id: 12, title: 'note', text: 'body' }
|
|
3460
|
+
]
|
|
3461
|
+
}, 'peer-a');
|
|
3462
|
+
if (!renderedState.includes('current task: #10 running State task') ||
|
|
3463
|
+
!renderedState.includes('next: none') ||
|
|
3464
|
+
!renderedState.includes('- hcc handoff create') ||
|
|
3465
|
+
!renderedState.includes('review locks before commit') ||
|
|
3466
|
+
!renderedState.includes('message:12 note')) {
|
|
3467
|
+
fail(`state render state output changed:\n${renderedState}`);
|
|
3468
|
+
}
|
|
3469
|
+
for (const helper of [
|
|
3470
|
+
'function helpMain',
|
|
3471
|
+
'function helpTask',
|
|
3472
|
+
'function helpTeam',
|
|
3473
|
+
'function helpState',
|
|
3474
|
+
'function helpJoin',
|
|
3475
|
+
'function helpEnv',
|
|
3476
|
+
'function helpMsg',
|
|
3477
|
+
'function helpAsk',
|
|
3478
|
+
'function helpBroadcast',
|
|
3479
|
+
'function helpInject',
|
|
3480
|
+
'function helpPeer',
|
|
3481
|
+
'function helpLock',
|
|
3482
|
+
'function helpHandoff',
|
|
3483
|
+
'function helpEvent',
|
|
3484
|
+
'function helpRun',
|
|
3485
|
+
'function helpUp',
|
|
3486
|
+
'function helpDown',
|
|
3487
|
+
'function helpUpdate',
|
|
3488
|
+
'function helpUninstall',
|
|
3489
|
+
'function helpWeb'
|
|
3490
|
+
]) {
|
|
3491
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds help text helper: ${helper}`);
|
|
3492
|
+
}
|
|
3493
|
+
const helpModule = await import(path.join(repoRoot, 'lib', 'ui', 'help.mjs'));
|
|
3494
|
+
const compatHelpModule = await import(path.join(repoRoot, 'lib', 'help.mjs'));
|
|
3495
|
+
if (typeof helpModule.createHelpFunctions !== 'function') fail('help module missing createHelpFunctions export');
|
|
3496
|
+
if (typeof compatHelpModule.createHelpFunctions !== 'function') fail('help compat module missing createHelpFunctions export');
|
|
3497
|
+
const capturedHelp = [];
|
|
3498
|
+
const savedConsoleLog = console.log;
|
|
3499
|
+
try {
|
|
3500
|
+
console.log = (value = '') => capturedHelp.push(String(value));
|
|
3501
|
+
const helpFns = helpModule.createHelpFunctions({
|
|
3502
|
+
productName: 'product-x',
|
|
3503
|
+
version: '1.2.3',
|
|
3504
|
+
cliName: 'hccx',
|
|
3505
|
+
npmPackageName: '@scope/pkg-x'
|
|
3506
|
+
});
|
|
3507
|
+
for (const name of [
|
|
3508
|
+
'helpMain',
|
|
3509
|
+
'helpTask',
|
|
3510
|
+
'helpTeam',
|
|
3511
|
+
'helpState',
|
|
3512
|
+
'helpJoin',
|
|
3513
|
+
'helpEnv',
|
|
3514
|
+
'helpMsg',
|
|
3515
|
+
'helpAsk',
|
|
3516
|
+
'helpBroadcast',
|
|
3517
|
+
'helpInject',
|
|
3518
|
+
'helpPeer',
|
|
3519
|
+
'helpLock',
|
|
3520
|
+
'helpHandoff',
|
|
3521
|
+
'helpEvent',
|
|
3522
|
+
'helpRun',
|
|
3523
|
+
'helpUp',
|
|
3524
|
+
'helpDown',
|
|
3525
|
+
'helpUpdate',
|
|
3526
|
+
'helpUninstall',
|
|
3527
|
+
'helpWeb'
|
|
3528
|
+
]) {
|
|
3529
|
+
if (typeof helpFns[name] !== 'function') fail(`help factory missing function: ${name}`);
|
|
3530
|
+
}
|
|
3531
|
+
helpFns.helpMain();
|
|
3532
|
+
helpFns.helpUpdate();
|
|
3533
|
+
helpFns.helpUninstall();
|
|
3534
|
+
helpFns.helpWeb();
|
|
3535
|
+
} finally {
|
|
3536
|
+
console.log = savedConsoleLog;
|
|
3537
|
+
}
|
|
3538
|
+
const [factoryMainHelp, factoryUpdateHelp, factoryUninstallHelp, factoryWebHelp] = capturedHelp;
|
|
3539
|
+
if (!factoryMainHelp?.startsWith('product-x 1.2.3') ||
|
|
3540
|
+
!factoryMainHelp.includes('hccx [--root DIR]') ||
|
|
3541
|
+
!factoryUpdateHelp?.includes('npm install -g @scope/pkg-x@TAG') ||
|
|
3542
|
+
!factoryUninstallHelp?.includes('hccx uninstall [--purge --yes]') ||
|
|
3543
|
+
!factoryWebHelp?.includes("HCC_WEB_TOKEN='long-token' hccx web --port 8787")) {
|
|
3544
|
+
fail(`help factory output changed:\n${capturedHelp.join('\n---\n')}`);
|
|
3545
|
+
}
|
|
3546
|
+
for (const helper of [
|
|
3547
|
+
'function sendMessage(',
|
|
3548
|
+
'function queryInbox(',
|
|
3549
|
+
'function queryTimelineMessages(',
|
|
3550
|
+
'function getMessage(',
|
|
3551
|
+
'function queryMessageThread(',
|
|
3552
|
+
'function ackMessage('
|
|
3553
|
+
]) {
|
|
3554
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds message store helper: ${helper}`);
|
|
3555
|
+
}
|
|
3556
|
+
const messagesModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'messages.mjs'));
|
|
3557
|
+
const compatMessagesModule = await import(path.join(repoRoot, 'lib', 'messages.mjs'));
|
|
3558
|
+
if (typeof messagesModule.createMessageStore !== 'function') fail('messages module missing createMessageStore export');
|
|
3559
|
+
if (typeof compatMessagesModule.createMessageStore !== 'function') fail('messages compat module missing createMessageStore export');
|
|
3560
|
+
const messageEvents = [];
|
|
3561
|
+
const messageStore = messagesModule.createMessageStore({
|
|
3562
|
+
now: () => 1234,
|
|
3563
|
+
addEvent: (_db, type, actor, taskId, payload) => messageEvents.push({ type, actor, taskId, payload })
|
|
3564
|
+
});
|
|
3565
|
+
for (const name of [
|
|
3566
|
+
'ackMessage',
|
|
3567
|
+
'getMessage',
|
|
3568
|
+
'queryInbox',
|
|
3569
|
+
'queryMessageThread',
|
|
3570
|
+
'queryTimelineMessages',
|
|
3571
|
+
'sendMessage'
|
|
3572
|
+
]) {
|
|
3573
|
+
if (typeof messageStore[name] !== 'function') fail(`message store missing function: ${name}`);
|
|
3574
|
+
}
|
|
3575
|
+
const messageDb = new DatabaseSync(':memory:');
|
|
3576
|
+
try {
|
|
3577
|
+
messageDb.exec(`
|
|
3578
|
+
CREATE TABLE messages (
|
|
3579
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3580
|
+
sender TEXT NOT NULL,
|
|
3581
|
+
recipient TEXT,
|
|
3582
|
+
task_id INTEGER,
|
|
3583
|
+
kind TEXT NOT NULL DEFAULT 'note',
|
|
3584
|
+
body TEXT NOT NULL,
|
|
3585
|
+
reply_to INTEGER,
|
|
3586
|
+
thread_id INTEGER,
|
|
3587
|
+
created_at INTEGER NOT NULL
|
|
3588
|
+
);
|
|
3589
|
+
CREATE TABLE message_reads (
|
|
3590
|
+
message_id INTEGER NOT NULL,
|
|
3591
|
+
peer TEXT NOT NULL,
|
|
3592
|
+
read_at INTEGER NOT NULL,
|
|
3593
|
+
PRIMARY KEY (message_id, peer)
|
|
3594
|
+
);
|
|
3595
|
+
`);
|
|
3596
|
+
const firstId = messageStore.sendMessage(messageDb, 'alice', 'bob', 42, 'note', 'hello');
|
|
3597
|
+
const replyId = messageStore.sendMessage(messageDb, 'bob', 'alice', 42, 'reply', 'ack', {
|
|
3598
|
+
reply_to: firstId,
|
|
3599
|
+
thread_id: firstId
|
|
3600
|
+
});
|
|
3601
|
+
const inbox = messageStore.queryInbox(messageDb, 'bob', false, 10);
|
|
3602
|
+
const thread = messageStore.queryMessageThread(messageDb, replyId, 10);
|
|
3603
|
+
messageStore.ackMessage(messageDb, 'bob', inbox[0]);
|
|
3604
|
+
const unread = messageStore.queryInbox(messageDb, 'bob', false, 10);
|
|
3605
|
+
const timeline = messageStore.queryTimelineMessages(messageDb, 'bob', 10);
|
|
3606
|
+
if (firstId !== 1 ||
|
|
3607
|
+
replyId !== 2 ||
|
|
3608
|
+
inbox.length !== 1 ||
|
|
3609
|
+
inbox[0].thread_id !== firstId ||
|
|
3610
|
+
thread.thread_id !== firstId ||
|
|
3611
|
+
thread.messages.length !== 2 ||
|
|
3612
|
+
unread.length !== 0 ||
|
|
3613
|
+
timeline.length !== 2 ||
|
|
3614
|
+
messageEvents.map((event) => event.type).join(',') !== 'message.sent,message.sent,message.ack') {
|
|
3615
|
+
fail('message store smoke test changed expected send/inbox/thread/ack behavior');
|
|
3616
|
+
}
|
|
3617
|
+
} finally {
|
|
3618
|
+
messageDb.close();
|
|
3619
|
+
}
|
|
3620
|
+
for (const helper of [
|
|
3621
|
+
'function claimTaskRowsForPeer(',
|
|
3622
|
+
'function takeoverPolicyDetails(',
|
|
3623
|
+
'function takeOverTaskForPeer(',
|
|
3624
|
+
'function queryOpenTasks(',
|
|
3625
|
+
'function taskById(',
|
|
3626
|
+
'function teamChildren(',
|
|
3627
|
+
'function teamSummary(',
|
|
3628
|
+
'function claimNextTasksForPeer('
|
|
3629
|
+
]) {
|
|
3630
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds task store helper: ${helper}`);
|
|
3631
|
+
}
|
|
3632
|
+
const taskStoreModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'tasks.mjs'));
|
|
3633
|
+
const compatTaskStoreModule = await import(path.join(repoRoot, 'lib', 'task-store.mjs'));
|
|
3634
|
+
if (typeof taskStoreModule.createTaskStore !== 'function') fail('task store module missing createTaskStore export');
|
|
3635
|
+
if (typeof compatTaskStoreModule.createTaskStore !== 'function') fail('task store compat module missing createTaskStore export');
|
|
3636
|
+
const taskEvents = [];
|
|
3637
|
+
const taskMessages = [];
|
|
3638
|
+
const taskStore = taskStoreModule.createTaskStore({
|
|
3639
|
+
now: () => 2000,
|
|
3640
|
+
activePeerTtl: 60,
|
|
3641
|
+
addEvent: (_db, type, actor, taskId, payload) => taskEvents.push({ type, actor, taskId, payload }),
|
|
3642
|
+
sendMessage: (_db, sender, recipient, taskId, kind, body) => {
|
|
3643
|
+
taskMessages.push({ sender, recipient, taskId, kind, body });
|
|
3644
|
+
return taskMessages.length;
|
|
3645
|
+
}
|
|
3646
|
+
});
|
|
3647
|
+
for (const name of [
|
|
3648
|
+
'claimNextTasksForPeer',
|
|
3649
|
+
'claimTaskRowsForPeer',
|
|
3650
|
+
'queryOpenTasks',
|
|
3651
|
+
'takeOverTaskForPeer',
|
|
3652
|
+
'taskById',
|
|
3653
|
+
'teamChildren',
|
|
3654
|
+
'teamSummary',
|
|
3655
|
+
'takeoverPolicyDetails'
|
|
3656
|
+
]) {
|
|
3657
|
+
if (typeof taskStore[name] !== 'function') fail(`task store missing function: ${name}`);
|
|
3658
|
+
}
|
|
3659
|
+
const taskDb = new DatabaseSync(':memory:');
|
|
3660
|
+
try {
|
|
3661
|
+
taskDb.exec(`
|
|
3662
|
+
CREATE TABLE tasks (
|
|
3663
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3664
|
+
title TEXT NOT NULL,
|
|
3665
|
+
body TEXT,
|
|
3666
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
3667
|
+
assignee TEXT,
|
|
3668
|
+
owner TEXT,
|
|
3669
|
+
parent_id INTEGER,
|
|
3670
|
+
team_role TEXT,
|
|
3671
|
+
priority INTEGER NOT NULL DEFAULT 100,
|
|
3672
|
+
created_by TEXT,
|
|
3673
|
+
claimed_at INTEGER,
|
|
3674
|
+
completed_at INTEGER,
|
|
3675
|
+
created_at INTEGER NOT NULL,
|
|
3676
|
+
updated_at INTEGER NOT NULL
|
|
3677
|
+
);
|
|
3678
|
+
CREATE TABLE peers (
|
|
3679
|
+
id TEXT PRIMARY KEY,
|
|
3680
|
+
last_seen_at INTEGER NOT NULL
|
|
3681
|
+
);
|
|
3682
|
+
CREATE TABLE handoffs (
|
|
3683
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
3684
|
+
task_id INTEGER,
|
|
3685
|
+
summary TEXT
|
|
3686
|
+
);
|
|
3687
|
+
`);
|
|
3688
|
+
taskDb.prepare(`
|
|
3689
|
+
INSERT INTO tasks(title, status, assignee, owner, parent_id, team_role, priority, created_by, created_at, updated_at)
|
|
3690
|
+
VALUES
|
|
3691
|
+
('assigned pending', 'pending', 'bob', NULL, NULL, NULL, 10, 'alice', 1000, 1000),
|
|
3692
|
+
('stale owned', 'running', NULL, 'old-owner', NULL, NULL, 20, 'alice', 1000, 1000),
|
|
3693
|
+
('parent', 'pending', NULL, NULL, NULL, NULL, 30, 'alice', 1000, 1000),
|
|
3694
|
+
('child done', 'done', NULL, NULL, 3, 'worker', 31, 'alice', 1000, 1000),
|
|
3695
|
+
('next pending', 'pending', NULL, NULL, NULL, NULL, 40, 'alice', 1000, 1000)
|
|
3696
|
+
`).run();
|
|
3697
|
+
taskDb.prepare('INSERT INTO peers(id, last_seen_at) VALUES (?, ?)').run('old-owner', 1000);
|
|
3698
|
+
taskDb.prepare('INSERT INTO handoffs(task_id, summary) VALUES (?, ?)').run(4, 'child handoff');
|
|
3699
|
+
const claimed = taskStore.claimTaskRowsForPeer(taskDb, 'bob', [1]);
|
|
3700
|
+
const blockedReject = (() => {
|
|
3701
|
+
try {
|
|
3702
|
+
taskStore.takeOverTaskForPeer(taskDb, 'taker', 2, { reason: 'blocked policy', policy: 'blocked' });
|
|
3703
|
+
return false;
|
|
3704
|
+
} catch (err) {
|
|
3705
|
+
return err?.code === 'TAKEOVER_POLICY';
|
|
3706
|
+
}
|
|
3707
|
+
})();
|
|
3708
|
+
const taken = taskStore.takeOverTaskForPeer(taskDb, 'taker', 2, { reason: 'stale policy', policy: 'stale', staleAfter: 60 });
|
|
3709
|
+
const next = taskStore.claimNextTasksForPeer(taskDb, 'next-peer', { count: 1 });
|
|
3710
|
+
const openForBob = taskStore.queryOpenTasks(taskDb, 10, 'bob');
|
|
3711
|
+
const summary = taskStore.teamSummary(taskDb, 3);
|
|
3712
|
+
if (claimed.length !== 1 ||
|
|
3713
|
+
claimed[0].owner !== 'bob' ||
|
|
3714
|
+
!blockedReject ||
|
|
3715
|
+
taken.owner !== 'taker' ||
|
|
3716
|
+
taskMessages.length !== 1 ||
|
|
3717
|
+
!taskMessages[0].body.includes('Task #2 taken over by taker') ||
|
|
3718
|
+
next.tasks.length !== 1 ||
|
|
3719
|
+
next.tasks[0].owner !== 'next-peer' ||
|
|
3720
|
+
openForBob.length !== 1 ||
|
|
3721
|
+
summary.children.length !== 1 ||
|
|
3722
|
+
summary.counts.done !== 1 ||
|
|
3723
|
+
taskEvents.map((event) => event.type).join(',') !== 'task.claimed,task.takeover,task.claimed') {
|
|
3724
|
+
fail('task store smoke test changed expected claim/takeover/next/team behavior');
|
|
3725
|
+
}
|
|
3726
|
+
} finally {
|
|
3727
|
+
taskDb.close();
|
|
3728
|
+
}
|
|
3729
|
+
for (const helper of [
|
|
3730
|
+
'function parseTaskIds(',
|
|
3731
|
+
'function positiveIntOpt(',
|
|
3732
|
+
'function taskRowsText('
|
|
3733
|
+
]) {
|
|
3734
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds task CLI helper: ${helper}`);
|
|
3735
|
+
}
|
|
3736
|
+
const taskCliModule = await import(path.join(repoRoot, 'lib', 'task-cli.mjs'));
|
|
3737
|
+
for (const name of ['parseTaskIds', 'positiveIntOpt', 'taskRowsText']) {
|
|
3738
|
+
if (typeof taskCliModule[name] !== 'function') fail(`task CLI module missing function: ${name}`);
|
|
3739
|
+
}
|
|
3740
|
+
const parsedTaskIds = taskCliModule.parseTaskIds({
|
|
3741
|
+
id: ['1,2', '2'],
|
|
3742
|
+
ids: '3',
|
|
3743
|
+
_: ['4']
|
|
3744
|
+
});
|
|
3745
|
+
if (parsedTaskIds.join(',') !== '1,2,3,4') {
|
|
3746
|
+
fail(`task CLI parseTaskIds changed expected id normalization: ${parsedTaskIds.join(',')}`);
|
|
3747
|
+
}
|
|
3748
|
+
const parseRejects = (() => {
|
|
3749
|
+
try {
|
|
3750
|
+
taskCliModule.parseTaskIds({ id: 'bad' });
|
|
3751
|
+
return false;
|
|
3752
|
+
} catch (err) {
|
|
3753
|
+
return err?.code === 'BAD_ARGS';
|
|
3754
|
+
}
|
|
3755
|
+
})();
|
|
3756
|
+
const emptyRejects = (() => {
|
|
3757
|
+
try {
|
|
3758
|
+
taskCliModule.parseTaskIds({});
|
|
3759
|
+
return false;
|
|
3760
|
+
} catch (err) {
|
|
3761
|
+
return err?.code === 'BAD_ARGS';
|
|
3762
|
+
}
|
|
3763
|
+
})();
|
|
3764
|
+
const lowRejects = (() => {
|
|
3765
|
+
try {
|
|
3766
|
+
taskCliModule.positiveIntOpt({ count: '0' }, 'count', 1, { max: 5 });
|
|
3767
|
+
return false;
|
|
3768
|
+
} catch (err) {
|
|
3769
|
+
return err?.code === 'BAD_ARGS';
|
|
3770
|
+
}
|
|
3771
|
+
})();
|
|
3772
|
+
const highRejects = (() => {
|
|
3773
|
+
try {
|
|
3774
|
+
taskCliModule.positiveIntOpt({ count: '6' }, 'count', 1, { max: 5 });
|
|
3775
|
+
return false;
|
|
3776
|
+
} catch (err) {
|
|
3777
|
+
return err?.code === 'BAD_ARGS';
|
|
3778
|
+
}
|
|
3779
|
+
})();
|
|
3780
|
+
if (!parseRejects ||
|
|
3781
|
+
!emptyRejects ||
|
|
3782
|
+
!lowRejects ||
|
|
3783
|
+
!highRejects ||
|
|
3784
|
+
taskCliModule.positiveIntOpt({ count: '2' }, 'count', 1, { max: 5 }) !== 2 ||
|
|
3785
|
+
taskCliModule.taskRowsText([], 'claimed') !== 'no pending task' ||
|
|
3786
|
+
taskCliModule.taskRowsText([{ id: 7, title: 'demo' }], 'claimed') !== 'claimed task #7: demo') {
|
|
3787
|
+
fail('task CLI smoke test changed expected parse/count/render behavior');
|
|
3788
|
+
}
|
|
3789
|
+
for (const helper of [
|
|
3790
|
+
'const WEB_CHILD_ENV',
|
|
3791
|
+
'const LAUNCH_FINGERPRINT_ENV',
|
|
3792
|
+
'const PROVIDER_STATE_ENV',
|
|
3793
|
+
'const LAUNCH_ENV_IGNORED_KEYS',
|
|
3794
|
+
'function childSessionEnv',
|
|
3795
|
+
'function launchEnvironmentFingerprint',
|
|
3796
|
+
'function launchFingerprint',
|
|
3797
|
+
'function isLikelyShellCommand',
|
|
3798
|
+
'function isProviderFallbackWrapper',
|
|
3799
|
+
'function isRelaunchableProviderSession',
|
|
3800
|
+
'function tmuxProviderState',
|
|
3801
|
+
'function tmuxManagedSessionName',
|
|
3802
|
+
'function tmuxEnvironmentArgs',
|
|
3803
|
+
'function isolatedEnvCommandArgs'
|
|
3804
|
+
]) {
|
|
3805
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds session launch helper: ${helper}`);
|
|
3806
|
+
}
|
|
3807
|
+
const sessionLaunchSource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'sessions', 'launch.mjs'), 'utf8');
|
|
3808
|
+
const compatSessionLaunchSource = fs.readFileSync(path.join(repoRoot, 'lib', 'session-launch.mjs'), 'utf8');
|
|
3809
|
+
if (sessionLaunchSource.includes("from '../../terminal/") ||
|
|
3810
|
+
sessionLaunchSource.includes("from '../terminal/") ||
|
|
3811
|
+
sessionLaunchSource.includes('tmuxSessionEnvironmentValue')) {
|
|
3812
|
+
fail('core session launch module depends on terminal/tmux helpers');
|
|
3813
|
+
}
|
|
3814
|
+
if (!compatSessionLaunchSource.includes("from './core/sessions/launch.mjs'") ||
|
|
3815
|
+
!compatSessionLaunchSource.includes("from './terminal/tmux.mjs'")) {
|
|
3816
|
+
fail('session launch compatibility module does not re-export from the new boundaries');
|
|
3817
|
+
}
|
|
3818
|
+
const sessionLaunch = await import(path.join(repoRoot, 'lib', 'core', 'sessions', 'launch.mjs'));
|
|
3819
|
+
const compatSessionLaunch = await import(path.join(repoRoot, 'lib', 'session-launch.mjs'));
|
|
3820
|
+
const tmuxModuleForSessionLaunch = await import(path.join(repoRoot, 'lib', 'tmux.mjs'));
|
|
3821
|
+
for (const name of [
|
|
3822
|
+
'childSessionEnv',
|
|
3823
|
+
'launchEnvironmentFingerprint',
|
|
3824
|
+
'launchFingerprint',
|
|
3825
|
+
'isLikelyShellCommand',
|
|
3826
|
+
'isProviderFallbackWrapper',
|
|
3827
|
+
'isRelaunchableProviderSession',
|
|
3828
|
+
'isolatedEnvCommandArgs'
|
|
3829
|
+
]) {
|
|
3830
|
+
if (typeof sessionLaunch[name] !== 'function') fail(`session launch module missing export: ${name}`);
|
|
3831
|
+
if (compatSessionLaunch[name] !== sessionLaunch[name]) fail(`session launch compat export mismatch: ${name}`);
|
|
3832
|
+
}
|
|
3833
|
+
for (const name of [
|
|
3834
|
+
'tmuxProviderState',
|
|
3835
|
+
'tmuxManagedSessionName',
|
|
3836
|
+
'tmuxEnvironmentArgs'
|
|
3837
|
+
]) {
|
|
3838
|
+
if (typeof tmuxModuleForSessionLaunch[name] !== 'function') fail(`tmux module missing session launch adapter: ${name}`);
|
|
3839
|
+
if (compatSessionLaunch[name] !== tmuxModuleForSessionLaunch[name]) {
|
|
3840
|
+
fail(`session launch compat terminal export mismatch: ${name}`);
|
|
3841
|
+
}
|
|
3842
|
+
}
|
|
3843
|
+
if (sessionLaunch.WEB_CHILD_ENV !== 'HCC_WEB_CHILD' ||
|
|
3844
|
+
sessionLaunch.LAUNCH_FINGERPRINT_ENV !== 'HCC_LAUNCH_FINGERPRINT' ||
|
|
3845
|
+
sessionLaunch.PROVIDER_STATE_ENV !== 'HCC_PROVIDER_STATE') {
|
|
3846
|
+
fail('session launch env constant changed');
|
|
3847
|
+
}
|
|
3848
|
+
const childEnv = sessionLaunch.childSessionEnv({ EXTRA: '1' }, { HCC_WEB_CHILD: '1', BASE: '2' });
|
|
3849
|
+
if (childEnv.HCC_WEB_CHILD !== undefined || childEnv.BASE !== '2' || childEnv.EXTRA !== '1') {
|
|
3850
|
+
fail(`session launch childSessionEnv changed: ${JSON.stringify(childEnv)}`);
|
|
3851
|
+
}
|
|
3852
|
+
const launchEnvA = sessionLaunch.launchEnvironmentFingerprint({
|
|
3853
|
+
B: '2',
|
|
3854
|
+
A: '1',
|
|
3855
|
+
PWD: '/ignored',
|
|
3856
|
+
TMUX: 'ignored',
|
|
3857
|
+
HCC_PEER: 'ignored'
|
|
3858
|
+
});
|
|
3859
|
+
const launchEnvB = sessionLaunch.launchEnvironmentFingerprint({ A: '1', B: '2' });
|
|
3860
|
+
const launchEnvC = sessionLaunch.launchEnvironmentFingerprint({ A: '1', B: '3' });
|
|
3861
|
+
if (launchEnvA !== launchEnvB || launchEnvA === launchEnvC) {
|
|
3862
|
+
fail('session launch environment fingerprint filtering/sorting changed');
|
|
3863
|
+
}
|
|
3864
|
+
const launchFingerprintA = sessionLaunch.launchFingerprint({ command: 'cmd', cwd: '/tmp/a', env: { A: '1' } });
|
|
3865
|
+
const launchFingerprintB = sessionLaunch.launchFingerprint({ command: 'cmd', cwd: '/tmp/b', env: { A: '1' } });
|
|
3866
|
+
if (launchFingerprintA === launchFingerprintB) {
|
|
3867
|
+
fail('session launch fingerprint no longer includes cwd');
|
|
3868
|
+
}
|
|
3869
|
+
if (!sessionLaunch.isLikelyShellCommand('/bin/bash') ||
|
|
3870
|
+
!sessionLaunch.isLikelyShellCommand('-zsh') ||
|
|
3871
|
+
sessionLaunch.isLikelyShellCommand('node')) {
|
|
3872
|
+
fail('session launch shell command detection changed');
|
|
3873
|
+
}
|
|
3874
|
+
if (!sessionLaunch.isProviderFallbackWrapper(`${sessionLaunch.PROVIDER_STATE_ENV}=starting exec bash`) ||
|
|
3875
|
+
!sessionLaunch.isProviderFallbackWrapper('exec zsh') ||
|
|
3876
|
+
sessionLaunch.isProviderFallbackWrapper('node script.js')) {
|
|
3877
|
+
fail('session launch provider fallback detection changed');
|
|
3878
|
+
}
|
|
3879
|
+
if (!sessionLaunch.isRelaunchableProviderSession('shell', 'exec bash', { provider: 'codex' }) ||
|
|
3880
|
+
sessionLaunch.isRelaunchableProviderSession('shell', 'exec bash', {}) ||
|
|
3881
|
+
sessionLaunch.isRelaunchableProviderSession('codex', 'node script.js', {})) {
|
|
3882
|
+
fail('session launch relaunchable provider detection changed');
|
|
3883
|
+
}
|
|
3884
|
+
const managedSessionName = tmuxModuleForSessionLaunch.tmuxManagedSessionName({ root: '/tmp/hcc root' }, 'Bad Peer!');
|
|
3885
|
+
if (!managedSessionName.startsWith('hcc-') ||
|
|
3886
|
+
!managedSessionName.endsWith('-bad-peer') ||
|
|
3887
|
+
managedSessionName.length > 80) {
|
|
3888
|
+
fail(`session launch tmux session name changed: ${managedSessionName}`);
|
|
3889
|
+
}
|
|
3890
|
+
const tmuxEnvArgs = tmuxModuleForSessionLaunch.tmuxEnvironmentArgs({
|
|
3891
|
+
A: '1',
|
|
3892
|
+
TMUX: 'ignored',
|
|
3893
|
+
'BAD-NAME': 'ignored',
|
|
3894
|
+
B: 2,
|
|
3895
|
+
C: null
|
|
3896
|
+
});
|
|
3897
|
+
if (JSON.stringify(tmuxEnvArgs) !== JSON.stringify(['-e', 'A=1', '-e', 'B=2'])) {
|
|
3898
|
+
fail(`session launch tmux env args changed: ${JSON.stringify(tmuxEnvArgs)}`);
|
|
3899
|
+
}
|
|
3900
|
+
const isolatedEnvArgs = sessionLaunch.isolatedEnvCommandArgs({
|
|
3901
|
+
A: '1',
|
|
3902
|
+
TMUX_PANE: 'ignored',
|
|
3903
|
+
'BAD-NAME': 'ignored',
|
|
3904
|
+
B: 2,
|
|
3905
|
+
C: null
|
|
3906
|
+
});
|
|
3907
|
+
if (!['/usr/bin/env', 'env'].includes(isolatedEnvArgs[0]) ||
|
|
3908
|
+
isolatedEnvArgs[1] !== '-i' ||
|
|
3909
|
+
!isolatedEnvArgs.includes('A=1') ||
|
|
3910
|
+
!isolatedEnvArgs.includes('B=2') ||
|
|
3911
|
+
isolatedEnvArgs.some((arg) => arg.includes('TMUX_PANE') || arg.includes('BAD-NAME'))) {
|
|
3912
|
+
fail(`session launch isolated env args changed: ${JSON.stringify(isolatedEnvArgs)}`);
|
|
3913
|
+
}
|
|
3914
|
+
for (const helper of [
|
|
3915
|
+
'function runTmux',
|
|
3916
|
+
'function tmuxInstallHint',
|
|
3917
|
+
'function commandExists',
|
|
3918
|
+
'function runInstallCommand',
|
|
3919
|
+
'function tryInstallTmux',
|
|
3920
|
+
'function ensureTmuxAvailable',
|
|
3921
|
+
'function tmuxHasSession',
|
|
3922
|
+
'function tmuxSessionHasClients',
|
|
3923
|
+
'function tmuxKillSession',
|
|
3924
|
+
'function tmuxSessionEnvironmentValue',
|
|
3925
|
+
'function tmuxPaneInfo',
|
|
3926
|
+
'function tmuxCapturePane',
|
|
3927
|
+
'function tmuxCursorInfo',
|
|
3928
|
+
'function tmuxCursorPayload',
|
|
3929
|
+
'function tmuxSendKeys',
|
|
3930
|
+
'function tmuxSendRawLiteral',
|
|
3931
|
+
'function tmuxInCopyMode',
|
|
3932
|
+
'function tmuxExitCopyMode',
|
|
3933
|
+
'function tmuxPasteBuffer',
|
|
3934
|
+
'function readTmuxEscapeSequence',
|
|
3935
|
+
'function isTmuxRawControlChar',
|
|
3936
|
+
'function tmuxSendLiteral'
|
|
3937
|
+
]) {
|
|
3938
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds tmux helper: ${helper}`);
|
|
3939
|
+
}
|
|
3940
|
+
const tmuxModule = await import(path.join(repoRoot, 'lib', 'tmux.mjs'));
|
|
3941
|
+
for (const name of [
|
|
3942
|
+
'runTmux',
|
|
3943
|
+
'tryInstallTmux',
|
|
3944
|
+
'ensureTmuxAvailable',
|
|
3945
|
+
'tmuxHasSession',
|
|
3946
|
+
'tmuxSessionHasClients',
|
|
3947
|
+
'tmuxKillSession',
|
|
3948
|
+
'tmuxSessionEnvironmentValue',
|
|
3949
|
+
'tmuxLaunchFingerprint',
|
|
3950
|
+
'tmuxPaneInfo',
|
|
3951
|
+
'tmuxCapturePane',
|
|
3952
|
+
'tmuxCursorInfo',
|
|
3953
|
+
'tmuxCursorPayload',
|
|
3954
|
+
'tmuxSendLiteral'
|
|
3955
|
+
]) {
|
|
3956
|
+
if (typeof tmuxModule[name] !== 'function') fail(`tmux module missing export: ${name}`);
|
|
3957
|
+
}
|
|
3958
|
+
const cursorPayload = tmuxModule.tmuxCursorPayload('a\nb\nc\nd', {
|
|
3959
|
+
x: 3,
|
|
3960
|
+
y: 1,
|
|
3961
|
+
visible: true,
|
|
3962
|
+
history: 2,
|
|
3963
|
+
height: 4
|
|
3964
|
+
});
|
|
3965
|
+
if (JSON.stringify(cursorPayload) !== JSON.stringify({ row: 3, col: 3, visible: true })) {
|
|
3966
|
+
fail(`tmux cursor payload mapping changed: ${JSON.stringify(cursorPayload)}`);
|
|
3967
|
+
}
|
|
3968
|
+
const clampedCursorPayload = tmuxModule.tmuxCursorPayload('a\nb\nc\nd\ne', {
|
|
3969
|
+
x: 4,
|
|
3970
|
+
y: 1,
|
|
3971
|
+
visible: false,
|
|
3972
|
+
history: 5,
|
|
3973
|
+
height: 3
|
|
3974
|
+
});
|
|
3975
|
+
if (JSON.stringify(clampedCursorPayload) !== JSON.stringify({ row: 2, col: 4, visible: false }) ||
|
|
3976
|
+
tmuxModule.tmuxCursorPayload('', null) !== null) {
|
|
3977
|
+
fail(`tmux cursor payload clamp/null behavior changed: ${JSON.stringify(clampedCursorPayload)}`);
|
|
3978
|
+
}
|
|
3979
|
+
for (const helper of [
|
|
3980
|
+
'function providerSessionPeerId',
|
|
3981
|
+
'function providerSessionParts',
|
|
3982
|
+
'function inferPeerKind',
|
|
3983
|
+
'function hasResumeOpts',
|
|
3984
|
+
'function defaultSessionCommand',
|
|
3985
|
+
'function buildPeerCommand',
|
|
3986
|
+
'function buildCodexCommand',
|
|
3987
|
+
'function buildClaudeCommand',
|
|
3988
|
+
'function bindingFromRun',
|
|
3989
|
+
'function parseClaudeCommandArgs',
|
|
3990
|
+
'function parseCodexCommandArgs'
|
|
3991
|
+
]) {
|
|
3992
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds provider command helper: ${helper}`);
|
|
3993
|
+
}
|
|
3994
|
+
const providerCommandsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'integrations', 'providers.mjs'), 'utf8');
|
|
3995
|
+
const compatProviderCommandsSource = fs.readFileSync(path.join(repoRoot, 'lib', 'provider-commands.mjs'), 'utf8');
|
|
3996
|
+
if (!providerCommandsSource.includes("from '../core/peers/session.mjs'") ||
|
|
3997
|
+
providerCommandsSource.includes('function providerSessionPeerId') ||
|
|
3998
|
+
providerCommandsSource.includes('function providerSessionParts')) {
|
|
3999
|
+
fail('integrations provider module no longer delegates peer session helpers to core/peers/session.mjs');
|
|
4000
|
+
}
|
|
4001
|
+
if (!compatProviderCommandsSource.includes("from './integrations/providers.mjs'")) {
|
|
4002
|
+
fail('provider command compat module no longer re-exports integrations/providers.mjs');
|
|
4003
|
+
}
|
|
4004
|
+
const providerCommands = await import(path.join(repoRoot, 'lib', 'integrations', 'providers.mjs'));
|
|
4005
|
+
const compatProviderCommands = await import(path.join(repoRoot, 'lib', 'provider-commands.mjs'));
|
|
4006
|
+
const peerSession = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'session.mjs'));
|
|
4007
|
+
for (const name of [
|
|
4008
|
+
'providerSessionPeerId',
|
|
4009
|
+
'providerSessionParts',
|
|
4010
|
+
'inferPeerKind',
|
|
4011
|
+
'hasResumeOpts',
|
|
4012
|
+
'defaultSessionCommand',
|
|
4013
|
+
'buildPeerCommand',
|
|
4014
|
+
'buildCodexCommand',
|
|
4015
|
+
'buildClaudeCommand',
|
|
4016
|
+
'bindingFromRun',
|
|
4017
|
+
'parseClaudeCommandArgs',
|
|
4018
|
+
'parseCodexCommandArgs'
|
|
4019
|
+
]) {
|
|
4020
|
+
if (typeof providerCommands[name] !== 'function') fail(`provider command module missing export: ${name}`);
|
|
4021
|
+
if (typeof compatProviderCommands[name] !== 'function') fail(`provider command compat module missing export: ${name}`);
|
|
4022
|
+
}
|
|
4023
|
+
for (const name of ['providerSessionPeerId', 'providerSessionParts']) {
|
|
4024
|
+
if (typeof peerSession[name] !== 'function') fail(`peer session module missing export: ${name}`);
|
|
4025
|
+
if (providerCommands[name] !== peerSession[name]) fail(`provider command module no longer re-exports peer session helper: ${name}`);
|
|
4026
|
+
if (compatProviderCommands[name] !== providerCommands[name]) fail(`provider command compat module no longer re-exports helper: ${name}`);
|
|
4027
|
+
}
|
|
4028
|
+
const providerNameSession = peerSession.providerSessionParts('named-session');
|
|
4029
|
+
if (providerNameSession.provider_session_name !== 'named-session' || providerNameSession.provider_session_id !== null) {
|
|
4030
|
+
fail(`provider command module misclassified named session: ${JSON.stringify(providerNameSession)}`);
|
|
4031
|
+
}
|
|
4032
|
+
const providerUuidSession = peerSession.providerSessionParts('00000000-0000-0000-0000-000000000000');
|
|
4033
|
+
if (providerUuidSession.provider_session_id !== '00000000-0000-0000-0000-000000000000' || providerUuidSession.provider_session_name !== null) {
|
|
4034
|
+
fail(`provider command module misclassified UUID session: ${JSON.stringify(providerUuidSession)}`);
|
|
4035
|
+
}
|
|
4036
|
+
const builtClaude = providerCommands.buildPeerCommand('claude-peer', 'claude', { resume: 'named-session' }, []);
|
|
4037
|
+
if (builtClaude.command !== 'claude --resume named-session' ||
|
|
4038
|
+
builtClaude.binding.resume_mode !== 'resume' ||
|
|
4039
|
+
builtClaude.binding.provider_session_name !== 'named-session') {
|
|
4040
|
+
fail(`provider command module built wrong Claude resume command: ${JSON.stringify(builtClaude)}`);
|
|
4041
|
+
}
|
|
4042
|
+
const builtCodex = providerCommands.buildPeerCommand('codex-peer', 'codex', { resume: 'codex-session' }, []);
|
|
4043
|
+
if (builtCodex.command !== 'codex resume codex-session' ||
|
|
4044
|
+
builtCodex.binding.resume_mode !== 'resume' ||
|
|
4045
|
+
builtCodex.binding.provider_session_name !== 'codex-session') {
|
|
4046
|
+
fail(`provider command module built wrong Codex resume command: ${JSON.stringify(builtCodex)}`);
|
|
4047
|
+
}
|
|
4048
|
+
const parsedCodex = providerCommands.parseCodexCommandArgs(['codex', 'resume', '--model', 'gpt-test', 'codex-session']);
|
|
4049
|
+
if (parsedCodex.resume_mode !== 'resume' ||
|
|
4050
|
+
parsedCodex.resume_arg !== 'codex-session' ||
|
|
4051
|
+
parsedCodex.session.provider_session_name !== 'codex-session') {
|
|
4052
|
+
fail(`provider command module parsed Codex resume args wrong: ${JSON.stringify(parsedCodex)}`);
|
|
4053
|
+
}
|
|
4054
|
+
const parsedClaude = providerCommands.parseClaudeCommandArgs(['claude', '--resume', 'claude-session', '--fork-session']);
|
|
4055
|
+
if (parsedClaude.resume_mode !== 'fork-resume' ||
|
|
4056
|
+
parsedClaude.resume_arg !== 'claude-session' ||
|
|
4057
|
+
parsedClaude.session.provider_session_name !== null) {
|
|
4058
|
+
fail(`provider command module parsed Claude fork resume args wrong: ${JSON.stringify(parsedClaude)}`);
|
|
4059
|
+
}
|
|
4060
|
+
for (const helper of [
|
|
4061
|
+
'function bindingFromDetected(',
|
|
4062
|
+
'function peerBindingRuntimeRank(',
|
|
4063
|
+
'function comparePeerBindings(',
|
|
4064
|
+
'function dedupePeerBindingRows(',
|
|
4065
|
+
'function dedupeProviderSessionColumn(',
|
|
4066
|
+
'function dedupeRuntimeTargets(',
|
|
4067
|
+
'function dedupePeerBindings(',
|
|
4068
|
+
'function bindingHasProviderSession(',
|
|
4069
|
+
'function bindingProviderSessionValue(',
|
|
4070
|
+
'function bindingHasRuntime(',
|
|
4071
|
+
'function mergePeerBinding(',
|
|
4072
|
+
'function mergeRuntimeBinding(',
|
|
4073
|
+
'function findProviderSessionBinding(',
|
|
4074
|
+
'function canonicalizePeerBinding(',
|
|
4075
|
+
'function upsertPeerBinding(',
|
|
4076
|
+
'function upsertCanonicalPeerBinding('
|
|
4077
|
+
]) {
|
|
4078
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds peer binding helper: ${helper}`);
|
|
4079
|
+
}
|
|
4080
|
+
const peerBindings = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'bindings.mjs'));
|
|
4081
|
+
const peerReconcile = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'reconcile.mjs'));
|
|
4082
|
+
const peerBindingStoreModule = await import(path.join(repoRoot, 'lib', 'db', 'stores', 'peers.mjs'));
|
|
4083
|
+
const compatPeerBindings = await import(path.join(repoRoot, 'lib', 'peer-bindings.mjs'));
|
|
4084
|
+
for (const name of [
|
|
4085
|
+
'bindingFromDetected',
|
|
4086
|
+
'peerBindingRuntimeRank',
|
|
4087
|
+
'comparePeerBindings',
|
|
4088
|
+
'bindingHasProviderSession',
|
|
4089
|
+
'bindingProviderSessionValue',
|
|
4090
|
+
'bindingHasRuntime',
|
|
4091
|
+
'mergePeerBinding',
|
|
4092
|
+
'mergeRuntimeBinding'
|
|
4093
|
+
]) {
|
|
4094
|
+
if (typeof peerBindings[name] !== 'function') fail(`peer binding module missing export: ${name}`);
|
|
4095
|
+
if (typeof compatPeerBindings[name] !== 'function') fail(`peer binding compat module missing export: ${name}`);
|
|
4096
|
+
}
|
|
4097
|
+
if (typeof peerBindingStoreModule.createPeerBindingStore !== 'function') {
|
|
4098
|
+
fail('peer binding store module missing createPeerBindingStore');
|
|
4099
|
+
}
|
|
4100
|
+
if (typeof peerReconcile.reconcileRunningPeerBindings !== 'function') {
|
|
4101
|
+
fail('peer reconcile module missing reconcileRunningPeerBindings');
|
|
4102
|
+
}
|
|
4103
|
+
if (typeof compatPeerBindings.createPeerBindingStore !== 'function') {
|
|
4104
|
+
fail('peer binding compat module missing createPeerBindingStore');
|
|
4105
|
+
}
|
|
4106
|
+
const detectedBinding = peerBindings.bindingFromDetected({
|
|
4107
|
+
id: 'detected-peer',
|
|
4108
|
+
kind: 'claude',
|
|
4109
|
+
sessionId: 'detected-session',
|
|
4110
|
+
command: 'claude'
|
|
4111
|
+
});
|
|
4112
|
+
if (detectedBinding.peer !== 'detected-peer' ||
|
|
4113
|
+
detectedBinding.provider !== 'claude' ||
|
|
4114
|
+
detectedBinding.provider_session_name !== 'detected-session' ||
|
|
4115
|
+
detectedBinding.resume_mode !== 'detected' ||
|
|
4116
|
+
detectedBinding.runtime_session_id !== 'detected-peer') {
|
|
4117
|
+
fail(`peer binding module built wrong detected binding: ${JSON.stringify(detectedBinding)}`);
|
|
4118
|
+
}
|
|
4119
|
+
if (peerBindings.peerBindingRuntimeRank({ transport: 'tmux', runtime_target: '%1' }) <= peerBindings.peerBindingRuntimeRank({ transport: 'detected' })) {
|
|
4120
|
+
fail('peer binding runtime rank no longer prefers tmux runtime bindings over detected bindings');
|
|
4121
|
+
}
|
|
4122
|
+
const mergedRuntime = peerBindings.mergeRuntimeBinding(
|
|
4123
|
+
{ peer: 'runtime-peer', command: 'codex resume old', transport: 'tmux', runtime_session_id: 'runtime-peer', runtime_target: '%9' },
|
|
4124
|
+
{ peer: 'runtime-peer', provider: 'codex', provider_session_name: 'session-a', transport: 'hook', runtime_session_id: 'runtime-peer' }
|
|
4125
|
+
);
|
|
4126
|
+
if (mergedRuntime.transport !== 'tmux' || mergedRuntime.runtime_target !== '%9' || mergedRuntime.command !== 'codex resume old') {
|
|
4127
|
+
fail(`peer binding module did not preserve existing runtime binding: ${JSON.stringify(mergedRuntime)}`);
|
|
4128
|
+
}
|
|
4129
|
+
const mergedProviderSession = peerBindings.mergePeerBinding(
|
|
4130
|
+
{ peer: 'session-peer', provider: 'claude', provider_session_id: '00000000-0000-0000-0000-000000000000', provider_session_name: null, resume_mode: 'detected', resume_arg: null, transport: 'hook', runtime_session_id: 'session-peer' },
|
|
4131
|
+
{ peer: 'session-peer', provider: 'claude', provider_session_id: null, provider_session_name: null, resume_mode: 'attached', resume_arg: '%pane', command: 'tmux %pane (claude)', transport: 'tmux', runtime_session_id: 'session-peer', runtime_target: '%pane' }
|
|
4132
|
+
);
|
|
4133
|
+
if (mergedProviderSession.provider_session_id !== '00000000-0000-0000-0000-000000000000' ||
|
|
4134
|
+
mergedProviderSession.transport !== 'tmux' ||
|
|
4135
|
+
mergedProviderSession.runtime_target !== '%pane' ||
|
|
4136
|
+
mergedProviderSession.resume_mode !== 'detected') {
|
|
4137
|
+
fail(`peer binding module did not preserve known provider session during runtime attach: ${JSON.stringify(mergedProviderSession)}`);
|
|
4138
|
+
}
|
|
4139
|
+
const peerBindingEvents = [];
|
|
4140
|
+
const peerBindingStore = peerBindingStoreModule.createPeerBindingStore({
|
|
4141
|
+
now: () => 2000,
|
|
4142
|
+
addEvent: (_db, type, actor, taskId, payload) => peerBindingEvents.push({ type, actor, taskId, payload })
|
|
4143
|
+
});
|
|
4144
|
+
for (const name of [
|
|
4145
|
+
'canonicalizePeerBinding',
|
|
4146
|
+
'dedupePeerBindings',
|
|
4147
|
+
'dedupePeerBindingRows',
|
|
4148
|
+
'dedupeProviderSessionColumn',
|
|
4149
|
+
'dedupeRuntimeTargets',
|
|
4150
|
+
'findProviderSessionBinding',
|
|
4151
|
+
'upsertCanonicalPeerBinding',
|
|
4152
|
+
'upsertPeerBinding'
|
|
4153
|
+
]) {
|
|
4154
|
+
if (typeof peerBindingStore[name] !== 'function') fail(`peer binding store missing function: ${name}`);
|
|
4155
|
+
}
|
|
4156
|
+
const peerBindingDb = new DatabaseSync(':memory:');
|
|
4157
|
+
try {
|
|
4158
|
+
peerBindingDb.exec(`
|
|
4159
|
+
CREATE TABLE peer_bindings (
|
|
4160
|
+
peer TEXT PRIMARY KEY,
|
|
4161
|
+
provider TEXT NOT NULL,
|
|
4162
|
+
provider_session_id TEXT,
|
|
4163
|
+
provider_session_name TEXT,
|
|
4164
|
+
resume_mode TEXT NOT NULL DEFAULT 'new',
|
|
4165
|
+
resume_arg TEXT,
|
|
4166
|
+
command TEXT,
|
|
4167
|
+
transport TEXT NOT NULL,
|
|
4168
|
+
runtime_session_id TEXT,
|
|
4169
|
+
runtime_target TEXT,
|
|
4170
|
+
created_at INTEGER NOT NULL,
|
|
4171
|
+
updated_at INTEGER NOT NULL
|
|
4172
|
+
);
|
|
4173
|
+
`);
|
|
4174
|
+
const insertBinding = peerBindingDb.prepare(`
|
|
4175
|
+
INSERT INTO peer_bindings(
|
|
4176
|
+
peer, provider, provider_session_id, provider_session_name, resume_mode,
|
|
4177
|
+
resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
|
|
4178
|
+
) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
4179
|
+
`);
|
|
4180
|
+
insertBinding.run('detected-old', 'codex', 'same-session', 'detected', null, null, 'detected', 'detected-old', null, 1000, 1000);
|
|
4181
|
+
insertBinding.run('runtime-new', 'codex', 'same-session', 'resume', 'same-session', 'codex resume same-session', 'tmux', 'runtime-new', '%pane', 1000, 1100);
|
|
4182
|
+
const deleted = peerBindingStore.dedupePeerBindings(peerBindingDb);
|
|
4183
|
+
const dedupedRows = peerBindingDb.prepare('SELECT peer FROM peer_bindings ORDER BY peer').all();
|
|
4184
|
+
if (deleted !== undefined || dedupedRows.length !== 1 || dedupedRows[0].peer !== 'runtime-new') {
|
|
4185
|
+
fail(`peer binding dedupe did not keep runtime binding: ${JSON.stringify(dedupedRows)}`);
|
|
4186
|
+
}
|
|
4187
|
+
const canonical = peerBindingStore.upsertCanonicalPeerBinding(peerBindingDb, {
|
|
4188
|
+
peer: 'hook-peer',
|
|
4189
|
+
provider: 'codex',
|
|
4190
|
+
provider_session_id: null,
|
|
4191
|
+
provider_session_name: 'same-session',
|
|
4192
|
+
resume_mode: 'detected',
|
|
4193
|
+
resume_arg: null,
|
|
4194
|
+
command: null,
|
|
4195
|
+
transport: 'hook',
|
|
4196
|
+
runtime_session_id: 'hook-peer'
|
|
4197
|
+
}, true);
|
|
4198
|
+
if (canonical.peer !== 'runtime-new' || canonical.merged_from !== 'hook-peer') {
|
|
4199
|
+
fail(`peer binding canonicalization did not merge hook peer into runtime peer: ${JSON.stringify(canonical)}`);
|
|
4200
|
+
}
|
|
4201
|
+
const canonicalRow = peerBindingDb.prepare('SELECT peer, transport, runtime_target, provider_session_name FROM peer_bindings WHERE peer = ?').get('runtime-new');
|
|
4202
|
+
if (canonicalRow.transport !== 'tmux' || canonicalRow.runtime_target !== '%pane' || canonicalRow.provider_session_name !== 'same-session') {
|
|
4203
|
+
fail(`peer binding canonical upsert lost runtime fields: ${JSON.stringify(canonicalRow)}`);
|
|
4204
|
+
}
|
|
4205
|
+
if (!peerBindingEvents.some((event) => event.type === 'provider.session.deduped')) {
|
|
4206
|
+
fail(`peer binding dedupe did not emit provider.session.deduped: ${JSON.stringify(peerBindingEvents)}`);
|
|
4207
|
+
}
|
|
4208
|
+
} finally {
|
|
4209
|
+
peerBindingDb.close();
|
|
4210
|
+
}
|
|
4211
|
+
const reconcileDb = new DatabaseSync(':memory:');
|
|
4212
|
+
try {
|
|
4213
|
+
reconcileDb.exec(`
|
|
4214
|
+
CREATE TABLE peers (
|
|
4215
|
+
id TEXT PRIMARY KEY,
|
|
4216
|
+
kind TEXT NOT NULL,
|
|
4217
|
+
role TEXT,
|
|
4218
|
+
worktree TEXT,
|
|
4219
|
+
branch TEXT,
|
|
4220
|
+
pid INTEGER,
|
|
4221
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
4222
|
+
capabilities TEXT,
|
|
4223
|
+
created_at INTEGER NOT NULL,
|
|
4224
|
+
last_seen_at INTEGER NOT NULL
|
|
4225
|
+
);
|
|
4226
|
+
CREATE TABLE peer_bindings (
|
|
4227
|
+
peer TEXT PRIMARY KEY,
|
|
4228
|
+
provider TEXT NOT NULL,
|
|
4229
|
+
provider_session_id TEXT,
|
|
4230
|
+
provider_session_name TEXT,
|
|
4231
|
+
resume_mode TEXT NOT NULL DEFAULT 'new',
|
|
4232
|
+
resume_arg TEXT,
|
|
4233
|
+
command TEXT,
|
|
4234
|
+
transport TEXT NOT NULL,
|
|
4235
|
+
runtime_session_id TEXT,
|
|
4236
|
+
runtime_target TEXT,
|
|
4237
|
+
created_at INTEGER NOT NULL,
|
|
4238
|
+
updated_at INTEGER NOT NULL
|
|
4239
|
+
);
|
|
4240
|
+
CREATE TABLE events (
|
|
4241
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4242
|
+
type TEXT NOT NULL,
|
|
4243
|
+
actor TEXT,
|
|
4244
|
+
task_id INTEGER,
|
|
4245
|
+
payload TEXT,
|
|
4246
|
+
created_at INTEGER NOT NULL
|
|
4247
|
+
);
|
|
4248
|
+
CREATE UNIQUE INDEX idx_reconcile_provider_session_id
|
|
4249
|
+
ON peer_bindings(provider, provider_session_id)
|
|
4250
|
+
WHERE provider_session_id IS NOT NULL;
|
|
4251
|
+
CREATE UNIQUE INDEX idx_reconcile_provider_session_name
|
|
4252
|
+
ON peer_bindings(provider, provider_session_name)
|
|
4253
|
+
WHERE provider_session_name IS NOT NULL;
|
|
4254
|
+
`);
|
|
4255
|
+
const t = 3000;
|
|
4256
|
+
const insertPeer = reconcileDb.prepare(`
|
|
4257
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
4258
|
+
VALUES (?, ?, 'peer', ?, 'master', ?, ?, '', ?, ?)
|
|
4259
|
+
`);
|
|
4260
|
+
const insertBinding = reconcileDb.prepare(`
|
|
4261
|
+
INSERT INTO peer_bindings(
|
|
4262
|
+
peer, provider, provider_session_id, provider_session_name, resume_mode,
|
|
4263
|
+
resume_arg, command, transport, runtime_session_id, runtime_target, created_at, updated_at
|
|
4264
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 'tmux', ?, ?, ?, ?)
|
|
4265
|
+
`);
|
|
4266
|
+
insertPeer.run('hook-known-peer', 'claude', root, 111, 'running', t, t);
|
|
4267
|
+
insertBinding.run('hook-known-peer', 'claude', null, null, 'attached', '%1', 'tmux %1 (claude)', 'hook-known-peer', '%1', t, t);
|
|
4268
|
+
insertPeer.run('argv-peer', 'codex', root, 222, 'running', t, t);
|
|
4269
|
+
insertBinding.run('argv-peer', 'codex', null, null, 'unknown', '%2', 'codex resume', 'argv-peer', '%2', t, t);
|
|
4270
|
+
insertPeer.run('weak-peer', 'codex', root, 333, 'running', t, t);
|
|
4271
|
+
insertBinding.run('weak-peer', 'codex', null, null, 'unknown', '%3', 'codex resume', 'weak-peer', '%3', t, t);
|
|
4272
|
+
insertPeer.run('known-peer', 'claude', root, 444, 'running', t, t);
|
|
4273
|
+
insertBinding.run('known-peer', 'claude', '11111111-1111-1111-1111-111111111111', null, 'detected', null, null, 'known-peer', '%4', t, t);
|
|
4274
|
+
insertPeer.run('conflict-peer', 'claude', root, 555, 'running', t, t);
|
|
4275
|
+
insertBinding.run('conflict-peer', 'claude', '33333333-3333-3333-3333-333333333333', null, 'detected', null, null, 'conflict-peer', '%5', t, t);
|
|
4276
|
+
insertPeer.run('conflict-new-peer', 'claude', root, 556, 'running', t, t);
|
|
4277
|
+
insertBinding.run('conflict-new-peer', 'claude', null, null, 'attached', '%6', 'tmux %6 (claude)', 'conflict-new-peer', '%6', t, t);
|
|
4278
|
+
insertPeer.run('provider-mismatch-conflict', 'codex', root, 666, 'running', t, t);
|
|
4279
|
+
insertBinding.run('provider-mismatch-conflict', 'codex', null, 'mismatch-session', 'detected', null, null, 'provider-mismatch-conflict', '%7', t, t);
|
|
4280
|
+
insertPeer.run('provider-mismatch-new', 'shell', root, 667, 'running', t, t);
|
|
4281
|
+
insertBinding.run('provider-mismatch-new', 'shell', null, null, 'attached', '%8', 'tmux %8 (bash)', 'provider-mismatch-new', '%8', t, t);
|
|
4282
|
+
|
|
4283
|
+
const reconcileEvents = [];
|
|
4284
|
+
const reconciled = peerReconcile.reconcileRunningPeerBindings(reconcileDb, { root }, {
|
|
4285
|
+
now: () => 4000,
|
|
4286
|
+
panes: [
|
|
4287
|
+
{ pane: '%1', pid: 111, cwd: root },
|
|
4288
|
+
{ pane: '%2', pid: 222, cwd: root },
|
|
4289
|
+
{ pane: '%3', pid: 333, cwd: root },
|
|
4290
|
+
{ pane: '%4', pid: 444, cwd: root },
|
|
4291
|
+
{ pane: '%5', pid: 555, cwd: root },
|
|
4292
|
+
{ pane: '%6', pid: 556, cwd: root },
|
|
4293
|
+
{ pane: '%7', pid: 666, cwd: root },
|
|
4294
|
+
{ pane: '%8', pid: 667, cwd: root }
|
|
4295
|
+
],
|
|
4296
|
+
latestProviderSessionForPeer: (peer) => {
|
|
4297
|
+
if (peer === 'hook-known-peer') return '22222222-2222-2222-2222-222222222222';
|
|
4298
|
+
if (peer === 'conflict-new-peer') return '33333333-3333-3333-3333-333333333333';
|
|
4299
|
+
return null;
|
|
4300
|
+
},
|
|
4301
|
+
inspectProcess: (pid) => {
|
|
4302
|
+
if (pid === 222) return { kind: 'codex', provider_session: 'argv-session', source: 'process.argv.codex' };
|
|
4303
|
+
if (pid === 667) return { kind: 'codex', provider_session: 'mismatch-session', source: 'process.argv.codex' };
|
|
4304
|
+
return null;
|
|
4305
|
+
},
|
|
4306
|
+
addEvent: (_db, type, actor, taskId, payload) => reconcileEvents.push({ type, actor, taskId, payload })
|
|
4307
|
+
});
|
|
4308
|
+
if (reconciled.backfilled !== 2) {
|
|
4309
|
+
fail(`peer binding reconcile did not backfill exactly two strong-evidence rows: ${JSON.stringify(reconciled, null, 2)}`);
|
|
4310
|
+
}
|
|
4311
|
+
const hookKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name, resume_mode, resume_arg FROM peer_bindings WHERE peer = ?').get('hook-known-peer');
|
|
4312
|
+
if (hookKnown.provider_session_id !== '22222222-2222-2222-2222-222222222222' || hookKnown.provider_session_name !== null) {
|
|
4313
|
+
fail(`peer binding reconcile did not backfill hook event session id: ${JSON.stringify(hookKnown)}`);
|
|
4314
|
+
}
|
|
4315
|
+
const argvKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name, resume_mode, resume_arg FROM peer_bindings WHERE peer = ?').get('argv-peer');
|
|
4316
|
+
if (argvKnown.provider_session_id !== null || argvKnown.provider_session_name !== 'argv-session') {
|
|
4317
|
+
fail(`peer binding reconcile did not backfill process argv session name: ${JSON.stringify(argvKnown)}`);
|
|
4318
|
+
}
|
|
4319
|
+
const weakKnown = reconcileDb.prepare('SELECT provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('weak-peer');
|
|
4320
|
+
if (weakKnown.provider_session_id !== null || weakKnown.provider_session_name !== null) {
|
|
4321
|
+
fail(`peer binding reconcile wrote weak-evidence provider session: ${JSON.stringify(weakKnown)}`);
|
|
4322
|
+
}
|
|
4323
|
+
const conflictNew = reconcileDb.prepare('SELECT provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('conflict-new-peer');
|
|
4324
|
+
if (conflictNew.provider_session_id !== null || conflictNew.provider_session_name !== null ||
|
|
4325
|
+
!reconciled.results.some((result) => result.peer === 'conflict-new-peer' && result.reason === 'provider_session_conflict')) {
|
|
4326
|
+
fail(`peer binding reconcile did not skip conflicting provider session safely: ${JSON.stringify({ conflictNew, reconciled }, null, 2)}`);
|
|
4327
|
+
}
|
|
4328
|
+
const mismatchNew = reconcileDb.prepare('SELECT provider, provider_session_id, provider_session_name FROM peer_bindings WHERE peer = ?').get('provider-mismatch-new');
|
|
4329
|
+
if (mismatchNew.provider !== 'shell' || mismatchNew.provider_session_id !== null || mismatchNew.provider_session_name !== null ||
|
|
4330
|
+
!reconciled.results.some((result) => result.peer === 'provider-mismatch-new' && result.provider === 'codex' && result.reason === 'provider_session_conflict')) {
|
|
4331
|
+
fail(`peer binding reconcile did not use candidate provider for conflict detection: ${JSON.stringify({ mismatchNew, reconciled }, null, 2)}`);
|
|
4332
|
+
}
|
|
4333
|
+
if (!reconcileEvents.some((event) => event.type === 'provider.session.backfilled' && event.actor === 'hook-known-peer') ||
|
|
4334
|
+
!reconcileEvents.some((event) => event.type === 'provider.session.backfilled' && event.actor === 'argv-peer')) {
|
|
4335
|
+
fail(`peer binding reconcile did not emit backfill events: ${JSON.stringify(reconcileEvents)}`);
|
|
4336
|
+
}
|
|
4337
|
+
} finally {
|
|
4338
|
+
reconcileDb.close();
|
|
4339
|
+
}
|
|
4340
|
+
for (const helper of [
|
|
4341
|
+
'const WHOLE_LOCK_SCOPE',
|
|
4342
|
+
'function normalizeLockScope',
|
|
4343
|
+
'function scopedLockResource',
|
|
4344
|
+
'function lockBaseResource',
|
|
4345
|
+
'function lockScope',
|
|
4346
|
+
'function lockLabel',
|
|
4347
|
+
'function lockArgv',
|
|
4348
|
+
'function locksConflict'
|
|
4349
|
+
]) {
|
|
4350
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds lock helper: ${helper}`);
|
|
4351
|
+
}
|
|
4352
|
+
const locksModule = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'locks.mjs'));
|
|
4353
|
+
const compatLocksModule = await import(path.join(repoRoot, 'lib', 'locks.mjs'));
|
|
4354
|
+
for (const name of [
|
|
4355
|
+
'normalizeLockScope',
|
|
4356
|
+
'scopedLockResource',
|
|
4357
|
+
'lockBaseResource',
|
|
4358
|
+
'lockScope',
|
|
4359
|
+
'lockLabel',
|
|
4360
|
+
'lockArgv',
|
|
4361
|
+
'locksConflict'
|
|
4362
|
+
]) {
|
|
4363
|
+
if (typeof locksModule[name] !== 'function') fail(`locks module missing export: ${name}`);
|
|
4364
|
+
if (typeof compatLocksModule[name] !== 'function') fail(`locks compat module missing export: ${name}`);
|
|
4365
|
+
}
|
|
4366
|
+
const wholeLock = locksModule.scopedLockResource('bin/hcc.mjs', '');
|
|
4367
|
+
const scopedLock = locksModule.scopedLockResource('bin/hcc.mjs', 'provider-commands');
|
|
4368
|
+
if (wholeLock.resource !== 'bin/hcc.mjs' ||
|
|
4369
|
+
wholeLock.base_resource !== 'bin/hcc.mjs' ||
|
|
4370
|
+
wholeLock.scope !== '*') {
|
|
4371
|
+
fail(`locks module built wrong whole-resource lock: ${JSON.stringify(wholeLock)}`);
|
|
4372
|
+
}
|
|
4373
|
+
if (!scopedLock.resource.startsWith('scoped:') ||
|
|
4374
|
+
scopedLock.base_resource !== 'bin/hcc.mjs' ||
|
|
4375
|
+
scopedLock.scope !== 'provider-commands' ||
|
|
4376
|
+
locksModule.lockLabel(scopedLock) !== 'bin/hcc.mjs [provider-commands]') {
|
|
4377
|
+
fail(`locks module built wrong scoped lock: ${JSON.stringify(scopedLock)}`);
|
|
4378
|
+
}
|
|
4379
|
+
if (!locksModule.locksConflict(wholeLock, scopedLock) ||
|
|
4380
|
+
locksModule.locksConflict(scopedLock, locksModule.scopedLockResource('bin/hcc.mjs', 'tmux-helpers')) ||
|
|
4381
|
+
locksModule.locksConflict(scopedLock, locksModule.scopedLockResource('scripts/regression.mjs', 'provider-commands'))) {
|
|
4382
|
+
fail('locks module conflict behavior changed');
|
|
4383
|
+
}
|
|
4384
|
+
for (const helper of [
|
|
4385
|
+
'function splitCsvList',
|
|
4386
|
+
'function parseTeamItems',
|
|
4387
|
+
'function inferTeamItems',
|
|
4388
|
+
'function expandTeamWorkers',
|
|
4389
|
+
'function assignTeamWorkers'
|
|
4390
|
+
]) {
|
|
4391
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds team planning helper: ${helper}`);
|
|
4392
|
+
}
|
|
4393
|
+
const teamPlanning = await import(path.join(repoRoot, 'lib', 'core', 'coordination', 'teams.mjs'));
|
|
4394
|
+
const compatTeamPlanning = await import(path.join(repoRoot, 'lib', 'team-planning.mjs'));
|
|
4395
|
+
for (const name of [
|
|
4396
|
+
'splitCsvList',
|
|
4397
|
+
'parseTeamItems',
|
|
4398
|
+
'inferTeamItems',
|
|
4399
|
+
'expandTeamWorkers',
|
|
4400
|
+
'assignTeamWorkers'
|
|
4401
|
+
]) {
|
|
4402
|
+
if (typeof teamPlanning[name] !== 'function') fail(`team planning module missing export: ${name}`);
|
|
4403
|
+
if (typeof compatTeamPlanning[name] !== 'function') fail(`team planning compat module missing export: ${name}`);
|
|
4404
|
+
}
|
|
4405
|
+
const splitTeam = teamPlanning.splitCsvList(['codex:2, claude-a', ' docs-a ']);
|
|
4406
|
+
if (JSON.stringify(splitTeam) !== JSON.stringify(['codex:2', 'claude-a', 'docs-a'])) {
|
|
4407
|
+
fail(`team planning splitCsvList changed: ${JSON.stringify(splitTeam)}`);
|
|
4408
|
+
}
|
|
4409
|
+
const parsedTeam = teamPlanning.parseTeamItems({
|
|
4410
|
+
item: ['docs:Update docs', 'codex-a:tests:Add: regression']
|
|
4411
|
+
});
|
|
4412
|
+
if (parsedTeam.length !== 2 ||
|
|
4413
|
+
parsedTeam[0].role !== 'docs' ||
|
|
4414
|
+
parsedTeam[0].title !== 'Update docs' ||
|
|
4415
|
+
parsedTeam[1].assignee !== 'codex-a' ||
|
|
4416
|
+
parsedTeam[1].role !== 'tests' ||
|
|
4417
|
+
parsedTeam[1].title !== 'Add:regression') {
|
|
4418
|
+
fail(`team planning parseTeamItems changed: ${JSON.stringify(parsedTeam)}`);
|
|
4419
|
+
}
|
|
4420
|
+
const inferredTeam = teamPlanning.inferTeamItems({ title: 'Parent task' }, { count: '0' });
|
|
4421
|
+
if (inferredTeam.length !== 1 || inferredTeam[0].title !== 'Parent task / subtask 1') {
|
|
4422
|
+
fail(`team planning count normalization changed: ${JSON.stringify(inferredTeam)}`);
|
|
4423
|
+
}
|
|
4424
|
+
try {
|
|
4425
|
+
teamPlanning.inferTeamItems({ title: 'Parent task' }, { count: 'not-a-number' });
|
|
4426
|
+
fail('team planning accepted non-integer count');
|
|
4427
|
+
} catch (err) {
|
|
4428
|
+
if (err?.code !== 'BAD_ARGS') throw err;
|
|
4429
|
+
}
|
|
4430
|
+
const expandedWorkers = teamPlanning.expandTeamWorkers(['Codex:2', 'claude-a'], 42);
|
|
4431
|
+
if (JSON.stringify(expandedWorkers) !== JSON.stringify(['codex-team-42-1', 'codex-team-42-2', 'claude-a'])) {
|
|
4432
|
+
fail(`team planning worker expansion changed: ${JSON.stringify(expandedWorkers)}`);
|
|
4433
|
+
}
|
|
4434
|
+
const assignedTeam = teamPlanning.assignTeamWorkers(parsedTeam, ['codex:2'], 42);
|
|
4435
|
+
if (assignedTeam[0].assignee !== 'codex-team-42-1' || assignedTeam[1].assignee !== 'codex-a') {
|
|
4436
|
+
fail(`team planning worker assignment changed: ${JSON.stringify(assignedTeam)}`);
|
|
4437
|
+
}
|
|
4438
|
+
const peerFormat = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'format.mjs'));
|
|
4439
|
+
const compatPeerFormat = await import(path.join(repoRoot, 'lib', 'peer-format.mjs'));
|
|
4440
|
+
if (typeof peerFormat.sanitizePeerPart !== 'function' ||
|
|
4441
|
+
typeof peerFormat.shortHash !== 'function' ||
|
|
4442
|
+
typeof compatPeerFormat.sanitizePeerPart !== 'function' ||
|
|
4443
|
+
typeof compatPeerFormat.shortHash !== 'function' ||
|
|
4444
|
+
peerFormat.sanitizePeerPart('Bad Peer!', 'fallback') !== 'bad-peer' ||
|
|
4445
|
+
peerFormat.sanitizePeerPart('!!!', 'fallback') !== 'fallback' ||
|
|
4446
|
+
peerFormat.shortHash('hello') !== 'aaf4c61d') {
|
|
4447
|
+
fail('peer format module behavior changed');
|
|
4448
|
+
}
|
|
4449
|
+
for (const [label, source, expectedImport] of [
|
|
4450
|
+
['peer session', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'session.mjs'), 'utf8'), "from './format.mjs'"],
|
|
4451
|
+
['team planning', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'coordination', 'teams.mjs'), 'utf8'), "from '../peers/format.mjs'"],
|
|
4452
|
+
['peer identity', fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'), 'utf8'), "from './format.mjs'"]
|
|
4453
|
+
]) {
|
|
4454
|
+
if (!source.includes(expectedImport)) fail(`${label} module does not import peer format helpers`);
|
|
4455
|
+
if (source.includes('function sanitizePeerPart') || source.includes('function shortHash')) {
|
|
4456
|
+
fail(`${label} module still embeds peer format helpers`);
|
|
4457
|
+
}
|
|
4458
|
+
}
|
|
4459
|
+
const corePeerIdentitySource = fs.readFileSync(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'), 'utf8');
|
|
4460
|
+
if (corePeerIdentitySource.includes('process.env') ||
|
|
4461
|
+
corePeerIdentitySource.includes('spawnSync(') ||
|
|
4462
|
+
corePeerIdentitySource.includes('/proc/')) {
|
|
4463
|
+
fail('core peer identity still contains process or procfs observation logic');
|
|
4464
|
+
}
|
|
4465
|
+
for (const helper of [
|
|
4466
|
+
'function sanitizePeerPart',
|
|
4467
|
+
'function shortHash',
|
|
4468
|
+
'function currentTtyName',
|
|
4469
|
+
'function readProcCmdline',
|
|
4470
|
+
'function readProcEnv',
|
|
4471
|
+
'function readProcParentPid',
|
|
4472
|
+
'function argsLookLikeCli',
|
|
4473
|
+
'function detectCliKindFromProcess',
|
|
4474
|
+
'function readAncestorCliInfo',
|
|
4475
|
+
'function resumeIdFromArgs',
|
|
4476
|
+
'function autoPeerProviderSession',
|
|
4477
|
+
'function autoPeerSessionId',
|
|
4478
|
+
'function autoPeerResumeId',
|
|
4479
|
+
'function autoPeerKind',
|
|
4480
|
+
'function autoPeerBasis',
|
|
4481
|
+
'function autoPeerId',
|
|
4482
|
+
'function resolveCurrentPeer',
|
|
4483
|
+
'function currentPeer'
|
|
4484
|
+
]) {
|
|
4485
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds peer identity helper: ${helper}`);
|
|
4486
|
+
}
|
|
4487
|
+
const peerIdentity = await import(path.join(repoRoot, 'lib', 'core', 'peers', 'identity.mjs'));
|
|
4488
|
+
const integrationPeerIdentity = await import(path.join(repoRoot, 'lib', 'integrations', 'peers', 'identity.mjs'));
|
|
4489
|
+
const compatPeerIdentity = await import(path.join(repoRoot, 'lib', 'peer-identity.mjs'));
|
|
4490
|
+
for (const name of [
|
|
4491
|
+
'sanitizePeerPart',
|
|
4492
|
+
'shortHash',
|
|
4493
|
+
'autoPeerProviderSession',
|
|
4494
|
+
'autoPeerSessionId',
|
|
4495
|
+
'autoPeerResumeId',
|
|
4496
|
+
'autoPeerKind',
|
|
4497
|
+
'autoPeerBasis',
|
|
4498
|
+
'autoPeerId',
|
|
4499
|
+
'resolveCurrentPeer',
|
|
4500
|
+
'currentPeer'
|
|
4501
|
+
]) {
|
|
4502
|
+
if (typeof peerIdentity[name] !== 'function') fail(`peer identity core module missing export: ${name}`);
|
|
4503
|
+
}
|
|
4504
|
+
for (const name of [
|
|
4505
|
+
'sanitizePeerPart',
|
|
4506
|
+
'shortHash',
|
|
4507
|
+
'currentTtyName',
|
|
4508
|
+
'readAncestorCliInfo',
|
|
4509
|
+
'resumeIdFromArgs',
|
|
4510
|
+
'autoPeerProviderSession',
|
|
4511
|
+
'autoPeerSessionId',
|
|
4512
|
+
'autoPeerResumeId',
|
|
4513
|
+
'autoPeerKind',
|
|
4514
|
+
'autoPeerBasis',
|
|
4515
|
+
'autoPeerId',
|
|
4516
|
+
'resolveCurrentPeer',
|
|
4517
|
+
'currentPeer'
|
|
4518
|
+
]) {
|
|
4519
|
+
if (typeof integrationPeerIdentity[name] !== 'function') fail(`peer identity integration module missing export: ${name}`);
|
|
4520
|
+
if (typeof compatPeerIdentity[name] !== 'function') fail(`peer identity compat module missing export: ${name}`);
|
|
4521
|
+
}
|
|
4522
|
+
if (peerIdentity.sanitizePeerPart('Bad Peer!', 'fallback') !== 'bad-peer' ||
|
|
4523
|
+
peerIdentity.sanitizePeerPart('!!!', 'fallback') !== 'fallback' ||
|
|
4524
|
+
peerIdentity.shortHash('hello') !== 'aaf4c61d') {
|
|
4525
|
+
fail('peer identity sanitize/hash behavior changed');
|
|
4526
|
+
}
|
|
4527
|
+
if (integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume', 'named-session']) !== 'named-session' ||
|
|
4528
|
+
integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume=inline-session']) !== 'inline-session' ||
|
|
4529
|
+
integrationPeerIdentity.resumeIdFromArgs('claude', ['claude', '--resume', 'named-session', '--fork-session']) !== null ||
|
|
4530
|
+
integrationPeerIdentity.resumeIdFromArgs('codex', ['codex', 'resume', 'codex-session']) !== 'codex-session' ||
|
|
4531
|
+
integrationPeerIdentity.resumeIdFromArgs('codex', ['codex', 'resume', '--last']) !== null) {
|
|
4532
|
+
fail('peer identity resume id parsing changed');
|
|
4533
|
+
}
|
|
4534
|
+
const savedPeerEnv = {
|
|
4535
|
+
HCC_PEER: process.env.HCC_PEER,
|
|
4536
|
+
CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID,
|
|
4537
|
+
CLAUDECODE: process.env.CLAUDECODE,
|
|
4538
|
+
CODEX_SESSION_ID: process.env.CODEX_SESSION_ID,
|
|
4539
|
+
CODEX_THREAD_ID: process.env.CODEX_THREAD_ID,
|
|
4540
|
+
CODEX_MANAGED_BY_NPM: process.env.CODEX_MANAGED_BY_NPM,
|
|
4541
|
+
CODEX_MANAGED_BY_BUN: process.env.CODEX_MANAGED_BY_BUN
|
|
4542
|
+
};
|
|
4543
|
+
try {
|
|
4544
|
+
process.env.HCC_PEER = 'env-peer';
|
|
4545
|
+
if (integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell').id !== 'env-peer') {
|
|
4546
|
+
fail('peer identity resolveCurrentPeer ignored HCC_PEER');
|
|
4547
|
+
}
|
|
4548
|
+
delete process.env.HCC_PEER;
|
|
4549
|
+
delete process.env.CLAUDE_CODE_SESSION_ID;
|
|
4550
|
+
delete process.env.CLAUDECODE;
|
|
4551
|
+
process.env.CODEX_SESSION_ID = '0123456789abcdef';
|
|
4552
|
+
delete process.env.CODEX_THREAD_ID;
|
|
4553
|
+
delete process.env.CODEX_MANAGED_BY_NPM;
|
|
4554
|
+
delete process.env.CODEX_MANAGED_BY_BUN;
|
|
4555
|
+
const autoPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, {}, 'peer', 'shell');
|
|
4556
|
+
if (!autoPeer.auto || autoPeer.id !== 'codex-01234567') {
|
|
4557
|
+
fail(`peer identity auto peer id changed: ${JSON.stringify(autoPeer)}`);
|
|
4558
|
+
}
|
|
4559
|
+
const explicitPeer = integrationPeerIdentity.resolveCurrentPeer({ root: repoRoot }, { peer: 'manual-peer' }, 'peer', 'shell');
|
|
4560
|
+
if (explicitPeer.auto || explicitPeer.id !== 'manual-peer') {
|
|
4561
|
+
fail(`peer identity explicit peer resolution changed: ${JSON.stringify(explicitPeer)}`);
|
|
4562
|
+
}
|
|
4563
|
+
} finally {
|
|
4564
|
+
for (const [key, value] of Object.entries(savedPeerEnv)) {
|
|
4565
|
+
if (value === undefined) delete process.env[key];
|
|
4566
|
+
else process.env[key] = value;
|
|
4567
|
+
}
|
|
4568
|
+
}
|
|
4569
|
+
for (const helper of [
|
|
4570
|
+
'function projectRecord',
|
|
4571
|
+
'function readProjectRegistry',
|
|
4572
|
+
'function writeProjectRegistry',
|
|
4573
|
+
'function registerProject',
|
|
4574
|
+
'function registerProjectActivity'
|
|
4575
|
+
]) {
|
|
4576
|
+
if (hccSource.includes(helper)) fail(`CLI still embeds project registry helper: ${helper}`);
|
|
4577
|
+
}
|
|
4578
|
+
const projectRegistry = await import(path.join(repoRoot, 'lib', 'runtime', 'projects.mjs'));
|
|
4579
|
+
const compatProjectRegistry = await import(path.join(repoRoot, 'lib', 'project-registry.mjs'));
|
|
4580
|
+
for (const name of [
|
|
4581
|
+
'projectRecord',
|
|
4582
|
+
'readProjectRegistry',
|
|
4583
|
+
'writeProjectRegistry',
|
|
4584
|
+
'registerProject',
|
|
4585
|
+
'registerProjectActivity'
|
|
4586
|
+
]) {
|
|
4587
|
+
if (typeof projectRegistry[name] !== 'function') fail(`project registry module missing export: ${name}`);
|
|
4588
|
+
if (typeof compatProjectRegistry[name] !== 'function') fail(`project registry compat module missing export: ${name}`);
|
|
4589
|
+
}
|
|
4590
|
+
const savedHome = process.env.HOME;
|
|
4591
|
+
process.env.HOME = home;
|
|
4592
|
+
try {
|
|
4593
|
+
const registryRootA = path.join(root, 'registry-a');
|
|
4594
|
+
const registryRootB = path.join(root, 'registry-b');
|
|
4595
|
+
const missingRegistryRoot = path.join(root, 'registry-missing');
|
|
4596
|
+
fs.mkdirSync(registryRootA, { recursive: true });
|
|
4597
|
+
fs.mkdirSync(registryRootB, { recursive: true });
|
|
4598
|
+
const written = projectRegistry.writeProjectRegistry([
|
|
4599
|
+
{ root: registryRootA, db: '', name: '', last_seen_at: '5' },
|
|
4600
|
+
{ root: missingRegistryRoot, db: path.join(missingRegistryRoot, 'stale.db'), name: 'Gone', last_seen_at: '20' },
|
|
4601
|
+
{ root: registryRootB, db: path.join(registryRootB, 'custom.db'), name: 'Bee', last_seen_at: '10' },
|
|
4602
|
+
{ root: registryRootA, db: path.join(registryRootA, 'new.db'), name: 'Aye', last_seen_at: '15' }
|
|
4603
|
+
]);
|
|
4604
|
+
if (written.length !== 3 ||
|
|
4605
|
+
written[0].root !== path.resolve(missingRegistryRoot) ||
|
|
4606
|
+
written[1].root !== path.resolve(registryRootA) ||
|
|
4607
|
+
written[1].db !== path.resolve(path.join(registryRootA, 'new.db')) ||
|
|
4608
|
+
written[1].name !== 'Aye' ||
|
|
4609
|
+
written[1].last_seen_at !== 15 ||
|
|
4610
|
+
written[2].root !== path.resolve(registryRootB)) {
|
|
4611
|
+
fail(`project registry write/dedupe/sort changed: ${JSON.stringify(written)}`);
|
|
4612
|
+
}
|
|
4613
|
+
const readBack = projectRegistry.readProjectRegistry();
|
|
4614
|
+
if (readBack.length !== 2 ||
|
|
4615
|
+
readBack[0].root !== path.resolve(registryRootA) ||
|
|
4616
|
+
readBack[1].root !== path.resolve(registryRootB) ||
|
|
4617
|
+
readBack.some((p) => p.root === path.resolve(missingRegistryRoot))) {
|
|
4618
|
+
fail(`project registry kept missing root: ${JSON.stringify(readBack)}`);
|
|
4619
|
+
}
|
|
4620
|
+
const recorded = projectRegistry.projectRecord({
|
|
4621
|
+
root: path.resolve(registryRootB),
|
|
4622
|
+
dbPath: path.join(registryRootB, 'mesh.db')
|
|
4623
|
+
}, () => 123);
|
|
4624
|
+
if (recorded.root !== path.resolve(registryRootB) ||
|
|
4625
|
+
recorded.db !== path.join(registryRootB, 'mesh.db') ||
|
|
4626
|
+
recorded.name !== 'registry-b' ||
|
|
4627
|
+
recorded.last_seen_at !== 123) {
|
|
4628
|
+
fail(`project registry record changed: ${JSON.stringify(recorded)}`);
|
|
4629
|
+
}
|
|
4630
|
+
const registered = projectRegistry.registerProject({
|
|
4631
|
+
root: path.resolve(registryRootB),
|
|
4632
|
+
dbPath: path.join(registryRootB, 'mesh.db')
|
|
4633
|
+
});
|
|
4634
|
+
if (registered[0].root !== path.resolve(registryRootB) ||
|
|
4635
|
+
registered.filter((p) => p.root === path.resolve(registryRootB)).length !== 1) {
|
|
4636
|
+
fail(`project registry register changed: ${JSON.stringify(registered)}`);
|
|
4637
|
+
}
|
|
4638
|
+
} finally {
|
|
4639
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
4640
|
+
else process.env.HOME = savedHome;
|
|
4641
|
+
}
|
|
2248
4642
|
for (const helper of [
|
|
2249
4643
|
'function runtimeBaseUrl',
|
|
2250
4644
|
'function runtimeApiUrl',
|
|
2251
4645
|
'function requestUrl',
|
|
4646
|
+
'function isLoopbackHost',
|
|
4647
|
+
'function nextSessionId',
|
|
4648
|
+
'function listenServer',
|
|
2252
4649
|
'function runtimeUrlQuery',
|
|
2253
4650
|
'function makeWebToken',
|
|
2254
4651
|
'function validateWebTokenOpts',
|
|
@@ -2271,9 +4668,26 @@ async function syntaxAndHelp() {
|
|
|
2271
4668
|
}
|
|
2272
4669
|
if (hccSource.includes('new URL(req.url')) fail('CLI still embeds raw server request URL parsing');
|
|
2273
4670
|
if (hccSource.includes('function webIndexHtml()')) fail('CLI still embeds the web UI template');
|
|
2274
|
-
const webRuntime = await import(path.join(repoRoot, 'lib', 'web
|
|
2275
|
-
const webHttp = await import(path.join(repoRoot, 'lib', 'web
|
|
2276
|
-
const webUiTemplate = await import(path.join(repoRoot, 'lib', 'web
|
|
4671
|
+
const webRuntime = await import(path.join(repoRoot, 'lib', 'web', 'runtime.mjs'));
|
|
4672
|
+
const webHttp = await import(path.join(repoRoot, 'lib', 'web', 'http.mjs'));
|
|
4673
|
+
const webUiTemplate = await import(path.join(repoRoot, 'lib', 'web', 'ui-template.mjs'));
|
|
4674
|
+
const compatWebRuntime = await import(path.join(repoRoot, 'lib', 'web-runtime.mjs'));
|
|
4675
|
+
const compatWebHttp = await import(path.join(repoRoot, 'lib', 'web-http.mjs'));
|
|
4676
|
+
const compatWebPeerActions = await import(path.join(repoRoot, 'lib', 'web-peer-actions.mjs'));
|
|
4677
|
+
const compatWebUiTemplate = await import(path.join(repoRoot, 'lib', 'web-ui-template.mjs'));
|
|
4678
|
+
for (const [moduleName, mod, names] of [
|
|
4679
|
+
['web/runtime', webRuntime, ['runtimeConnectHost', 'runtimeBaseUrl', 'runtimeApiUrl', 'requestUrl', 'isLoopbackHost', 'nextSessionId', 'listenServer', 'publicRuntimeUrl', 'localRuntimeUrl', 'makeWebToken', 'expectedWebHost', 'webRuntimeMatchesRequest', 'rememberRuntimeToken']],
|
|
4680
|
+
['web-runtime compat', compatWebRuntime, ['runtimeConnectHost', 'runtimeBaseUrl', 'runtimeApiUrl', 'requestUrl', 'isLoopbackHost', 'nextSessionId', 'listenServer', 'publicRuntimeUrl', 'localRuntimeUrl', 'makeWebToken', 'expectedWebHost', 'webRuntimeMatchesRequest', 'rememberRuntimeToken']],
|
|
4681
|
+
['web/http', webHttp, ['readJsonRequest', 'sendHttp', 'sendJson', 'sendFile', 'authOk']],
|
|
4682
|
+
['web-http compat', compatWebHttp, ['readJsonRequest', 'sendHttp', 'sendJson', 'sendFile', 'authOk']],
|
|
4683
|
+
['web-peer-actions compat', compatWebPeerActions, ['createWebPeerActions']],
|
|
4684
|
+
['web/ui-template', webUiTemplate, ['webIndexHtml']],
|
|
4685
|
+
['web-ui-template compat', compatWebUiTemplate, ['webIndexHtml']]
|
|
4686
|
+
]) {
|
|
4687
|
+
for (const name of names) {
|
|
4688
|
+
if (typeof mod[name] !== 'function') fail(`${moduleName} missing export: ${name}`);
|
|
4689
|
+
}
|
|
4690
|
+
}
|
|
2277
4691
|
const expectEqual = (actual, expected, label) => {
|
|
2278
4692
|
if (actual !== expected) fail(`${label}: expected ${expected}, got ${actual}`);
|
|
2279
4693
|
};
|
|
@@ -2318,6 +4732,26 @@ async function syntaxAndHelp() {
|
|
|
2318
4732
|
expectEqual(String(webRuntime.runtimeApiUrl({ base_url: 'http://127.0.0.1:8787/base' }, '/api/state?peer=a b')), 'http://127.0.0.1:8787/api/state?peer=a%20b', 'runtimeApiUrl route');
|
|
2319
4733
|
expectEqual(String(webRuntime.requestUrl({ url: '/api/state?peer=a b', headers: { host: 'example.test:8787' } })), 'http://example.test:8787/api/state?peer=a%20b', 'requestUrl host and query');
|
|
2320
4734
|
expectEqual(String(webRuntime.requestUrl({ url: '', headers: {} })), 'http://localhost/', 'requestUrl fallback');
|
|
4735
|
+
if (!webRuntime.isLoopbackHost('127.0.0.1') ||
|
|
4736
|
+
!webRuntime.isLoopbackHost('localhost') ||
|
|
4737
|
+
!webRuntime.isLoopbackHost('::1') ||
|
|
4738
|
+
webRuntime.isLoopbackHost('0.0.0.0')) {
|
|
4739
|
+
fail('web runtime isLoopbackHost checks failed');
|
|
4740
|
+
}
|
|
4741
|
+
expectEqual(webRuntime.nextSessionId(['shell-1', 'shell-2'], 'shell'), 'shell-3', 'nextSessionId array');
|
|
4742
|
+
expectEqual(webRuntime.nextSessionId(new Map([
|
|
4743
|
+
['a', { id: 'codex-1' }],
|
|
4744
|
+
['b', 'codex-2']
|
|
4745
|
+
]), 'codex'), 'codex-3', 'nextSessionId map');
|
|
4746
|
+
const listenProbe = http.createServer((req, res) => res.end('ok'));
|
|
4747
|
+
try {
|
|
4748
|
+
const listenPort = await webRuntime.listenServer(listenProbe, '127.0.0.1', 0, false);
|
|
4749
|
+
if (!Number.isInteger(listenPort) || listenPort <= 0) {
|
|
4750
|
+
fail(`listenServer did not return a usable port: ${listenPort}`);
|
|
4751
|
+
}
|
|
4752
|
+
} finally {
|
|
4753
|
+
await new Promise((resolve) => listenProbe.close(resolve));
|
|
4754
|
+
}
|
|
2321
4755
|
expectEqual(webRuntime.publicRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl wildcard');
|
|
2322
4756
|
expectEqual(webRuntime.localRuntimeUrl(wildcardRuntime, '/tmp/hcc project'), 'http://127.0.0.1:8787/?token=tok&project=%2Ftmp%2Fhcc%20project', 'localRuntimeUrl wildcard');
|
|
2323
4757
|
expectEqual(webRuntime.publicRuntimeUrl(ipv6WildcardRuntime, '/tmp/hcc project'), 'http://<machine-ip>:8788/?token=tok&project=%2Ftmp%2Fhcc%20project', 'publicRuntimeUrl ipv6 wildcard');
|
|
@@ -2338,6 +4772,9 @@ async function syntaxAndHelp() {
|
|
|
2338
4772
|
if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
|
|
2339
4773
|
fail(`main help missing uninstall command:\n${mainHelp}`);
|
|
2340
4774
|
}
|
|
4775
|
+
if (!mainHelp.includes(' tmux gc [--yes] Clean stale DB-proven hcc-managed tmux sessions')) {
|
|
4776
|
+
fail(`main help missing tmux gc command:\n${mainHelp}`);
|
|
4777
|
+
}
|
|
2341
4778
|
const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
|
|
2342
4779
|
if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
|
|
2343
4780
|
fail(`msg help missing reply/thread commands:\n${msgHelp}`);
|
|
@@ -2373,6 +4810,19 @@ async function syntaxAndHelp() {
|
|
|
2373
4810
|
if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
|
|
2374
4811
|
fail(`update help missing expected content:\n${updateHelp}`);
|
|
2375
4812
|
}
|
|
4813
|
+
const tmuxHelp = run(process.execPath, [hccBin, 'tmux', '--help']);
|
|
4814
|
+
if (!tmuxHelp.includes('hcc tmux') ||
|
|
4815
|
+
!tmuxHelp.includes('tmux gc [--peer ID] [--older-than DAYS]') ||
|
|
4816
|
+
!tmuxHelp.includes('peer_bindings.transport must be tmux') ||
|
|
4817
|
+
!tmuxHelp.includes('deletion requires --yes')) {
|
|
4818
|
+
fail(`tmux help missing DB-backed gc semantics:\n${tmuxHelp}`);
|
|
4819
|
+
}
|
|
4820
|
+
const gcHelp = run(process.execPath, [hccBin, 'gc', '--help']);
|
|
4821
|
+
if (!gcHelp.includes('hcc gc') ||
|
|
4822
|
+
!gcHelp.includes('gc [--older-than DAYS] [--yes]') ||
|
|
4823
|
+
!gcHelp.includes('deletion requires --yes')) {
|
|
4824
|
+
fail(`gc help missing cleanup semantics:\n${gcHelp}`);
|
|
4825
|
+
}
|
|
2376
4826
|
const uninstallHelp = run(process.execPath, [hccBin, 'uninstall', '--help']);
|
|
2377
4827
|
if (!uninstallHelp.includes('hcc uninstall') || !uninstallHelp.includes('hcc uninstall [--purge --yes]')) {
|
|
2378
4828
|
fail(`uninstall help missing expected content:\n${uninstallHelp}`);
|
|
@@ -2395,6 +4845,7 @@ async function syntaxAndHelp() {
|
|
|
2395
4845
|
['task', 'done', 'hcc task done'],
|
|
2396
4846
|
['msg', 'reply', 'hcc msg reply'],
|
|
2397
4847
|
['peer', 'attach', 'peer attach'],
|
|
4848
|
+
['tmux', 'gc', 'hcc tmux'],
|
|
2398
4849
|
['lock', 'release', 'hcc lock release'],
|
|
2399
4850
|
['handoff', 'create', 'hcc handoff create'],
|
|
2400
4851
|
['event', 'tail', 'hcc event tail']
|