@logicseek/hello-cc 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +145 -3
- package/README.md +4 -1
- package/README.zh-CN.md +5 -1
- package/bin/hcc.mjs +2701 -384
- package/docs/README.md +5 -3
- package/docs/README.zh-CN.md +5 -3
- package/docs/commands.md +38 -7
- package/docs/commands.zh-CN.md +31 -6
- package/docs/design.md +12 -4
- package/docs/guide.md +55 -10
- package/docs/guide.zh-CN.md +47 -9
- package/docs/implementation.md +21 -1
- package/lib/guidance.mjs +115 -0
- package/lib/package-meta.mjs +24 -0
- package/lib/release-notes.mjs +40 -0
- package/package.json +3 -1
- package/scripts/github-release.mjs +152 -0
- package/scripts/regression.mjs +965 -33
- package/scripts/release-notes.mjs +6 -38
package/scripts/regression.mjs
CHANGED
|
@@ -107,7 +107,9 @@ function withMeshDb(fn) {
|
|
|
107
107
|
|
|
108
108
|
function providerBindingRows(provider, sessionName) {
|
|
109
109
|
return withMeshDb((db) => db.prepare(`
|
|
110
|
-
SELECT
|
|
110
|
+
SELECT
|
|
111
|
+
peer, provider, provider_session_id, provider_session_name,
|
|
112
|
+
resume_mode, resume_arg, command, transport, runtime_target
|
|
111
113
|
FROM peer_bindings
|
|
112
114
|
WHERE provider = ? AND provider_session_name = ?
|
|
113
115
|
ORDER BY peer
|
|
@@ -180,6 +182,42 @@ function insertRuntimeTargetBinding(peer, provider, sessionName, runtimeTarget)
|
|
|
180
182
|
});
|
|
181
183
|
}
|
|
182
184
|
|
|
185
|
+
function moveRuntimeBindingPeer(fromPeer, toPeer) {
|
|
186
|
+
withMeshDb((db) => {
|
|
187
|
+
const peer = db.prepare('SELECT * FROM peers WHERE id = ?').get(fromPeer);
|
|
188
|
+
if (!peer) fail(`cannot move missing peer ${fromPeer}`);
|
|
189
|
+
const binding = db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(fromPeer);
|
|
190
|
+
if (!binding) fail(`cannot move missing peer binding ${fromPeer}`);
|
|
191
|
+
const t = Math.floor(Date.now() / 1000);
|
|
192
|
+
db.prepare(`
|
|
193
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
196
|
+
kind = excluded.kind,
|
|
197
|
+
role = excluded.role,
|
|
198
|
+
worktree = excluded.worktree,
|
|
199
|
+
branch = excluded.branch,
|
|
200
|
+
pid = excluded.pid,
|
|
201
|
+
status = excluded.status,
|
|
202
|
+
capabilities = excluded.capabilities,
|
|
203
|
+
last_seen_at = excluded.last_seen_at
|
|
204
|
+
`).run(
|
|
205
|
+
toPeer,
|
|
206
|
+
peer.kind,
|
|
207
|
+
peer.role,
|
|
208
|
+
peer.worktree,
|
|
209
|
+
peer.branch,
|
|
210
|
+
peer.pid,
|
|
211
|
+
peer.status,
|
|
212
|
+
peer.capabilities,
|
|
213
|
+
t,
|
|
214
|
+
t
|
|
215
|
+
);
|
|
216
|
+
db.prepare('DELETE FROM peer_bindings WHERE peer = ?').run(toPeer);
|
|
217
|
+
db.prepare('UPDATE peer_bindings SET peer = ?, updated_at = ? WHERE peer = ?').run(toPeer, t, fromPeer);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
183
221
|
function assertSqliteUniqueFailure(label, fn) {
|
|
184
222
|
try {
|
|
185
223
|
fn();
|
|
@@ -283,6 +321,227 @@ function createLegacyBindingDb(dbPath) {
|
|
|
283
321
|
}
|
|
284
322
|
}
|
|
285
323
|
|
|
324
|
+
function createLegacySchemaDb(dbPath) {
|
|
325
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
326
|
+
const db = new DatabaseSync(dbPath);
|
|
327
|
+
try {
|
|
328
|
+
const t = Math.floor(Date.now() / 1000) - 3600;
|
|
329
|
+
db.exec(`
|
|
330
|
+
CREATE TABLE meta (
|
|
331
|
+
key TEXT PRIMARY KEY,
|
|
332
|
+
value TEXT NOT NULL
|
|
333
|
+
);
|
|
334
|
+
INSERT INTO meta(key, value) VALUES ('schema_version', '1');
|
|
335
|
+
|
|
336
|
+
CREATE TABLE peers (
|
|
337
|
+
id TEXT PRIMARY KEY,
|
|
338
|
+
kind TEXT NOT NULL,
|
|
339
|
+
role TEXT,
|
|
340
|
+
worktree TEXT,
|
|
341
|
+
branch TEXT,
|
|
342
|
+
pid INTEGER,
|
|
343
|
+
status TEXT NOT NULL DEFAULT 'idle',
|
|
344
|
+
capabilities TEXT,
|
|
345
|
+
created_at INTEGER NOT NULL,
|
|
346
|
+
last_seen_at INTEGER NOT NULL
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
CREATE TABLE peer_bindings (
|
|
350
|
+
peer TEXT PRIMARY KEY,
|
|
351
|
+
provider TEXT NOT NULL,
|
|
352
|
+
provider_session_id TEXT,
|
|
353
|
+
provider_session_name TEXT,
|
|
354
|
+
resume_mode TEXT NOT NULL DEFAULT 'new',
|
|
355
|
+
resume_arg TEXT,
|
|
356
|
+
command TEXT,
|
|
357
|
+
transport TEXT NOT NULL,
|
|
358
|
+
runtime_session_id TEXT,
|
|
359
|
+
created_at INTEGER NOT NULL,
|
|
360
|
+
updated_at INTEGER NOT NULL
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
CREATE TABLE tasks (
|
|
364
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
365
|
+
title TEXT NOT NULL,
|
|
366
|
+
body TEXT,
|
|
367
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
368
|
+
assignee TEXT,
|
|
369
|
+
owner TEXT,
|
|
370
|
+
priority INTEGER NOT NULL DEFAULT 100,
|
|
371
|
+
created_by TEXT,
|
|
372
|
+
claimed_at INTEGER,
|
|
373
|
+
completed_at INTEGER,
|
|
374
|
+
created_at INTEGER NOT NULL,
|
|
375
|
+
updated_at INTEGER NOT NULL
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
CREATE TABLE messages (
|
|
379
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
380
|
+
sender TEXT NOT NULL,
|
|
381
|
+
recipient TEXT,
|
|
382
|
+
task_id INTEGER,
|
|
383
|
+
kind TEXT NOT NULL DEFAULT 'note',
|
|
384
|
+
body TEXT NOT NULL,
|
|
385
|
+
created_at INTEGER NOT NULL
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
CREATE TABLE message_reads (
|
|
389
|
+
message_id INTEGER NOT NULL,
|
|
390
|
+
peer TEXT NOT NULL,
|
|
391
|
+
read_at INTEGER NOT NULL,
|
|
392
|
+
PRIMARY KEY (message_id, peer)
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
CREATE TABLE locks (
|
|
396
|
+
resource TEXT PRIMARY KEY,
|
|
397
|
+
owner TEXT NOT NULL,
|
|
398
|
+
task_id INTEGER,
|
|
399
|
+
reason TEXT,
|
|
400
|
+
expires_at INTEGER NOT NULL,
|
|
401
|
+
created_at INTEGER NOT NULL
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
CREATE TABLE handoffs (
|
|
405
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
406
|
+
task_id INTEGER,
|
|
407
|
+
from_peer TEXT NOT NULL,
|
|
408
|
+
to_peer TEXT,
|
|
409
|
+
summary TEXT NOT NULL,
|
|
410
|
+
changed_files TEXT,
|
|
411
|
+
tests TEXT,
|
|
412
|
+
risks TEXT,
|
|
413
|
+
created_at INTEGER NOT NULL
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
CREATE TABLE events (
|
|
417
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
418
|
+
type TEXT NOT NULL,
|
|
419
|
+
actor TEXT,
|
|
420
|
+
task_id INTEGER,
|
|
421
|
+
payload TEXT,
|
|
422
|
+
created_at INTEGER NOT NULL
|
|
423
|
+
);
|
|
424
|
+
|
|
425
|
+
CREATE INDEX idx_tasks_status_priority ON tasks(status, priority, id);
|
|
426
|
+
CREATE INDEX idx_tasks_owner ON tasks(owner);
|
|
427
|
+
CREATE INDEX idx_messages_recipient_id ON messages(recipient, id);
|
|
428
|
+
CREATE INDEX idx_events_id ON events(id);
|
|
429
|
+
CREATE INDEX idx_locks_expires ON locks(expires_at);
|
|
430
|
+
|
|
431
|
+
INSERT INTO peers(id, kind, role, worktree, branch, pid, status, capabilities, created_at, last_seen_at)
|
|
432
|
+
VALUES ('legacy-peer', 'codex', 'peer', '${root.replace(/'/g, "''")}', '', NULL, 'idle', 'legacy', ${t}, ${t});
|
|
433
|
+
INSERT INTO peer_bindings(peer, provider, provider_session_id, provider_session_name, resume_mode, resume_arg, command, transport, runtime_session_id, created_at, updated_at)
|
|
434
|
+
VALUES ('legacy-peer', 'codex', NULL, 'legacy-session', 'detected', NULL, NULL, 'detected', 'legacy-peer', ${t}, ${t});
|
|
435
|
+
INSERT INTO messages(sender, recipient, task_id, kind, body, created_at)
|
|
436
|
+
VALUES ('legacy-peer', 'all', NULL, 'note', 'legacy-message', ${t});
|
|
437
|
+
`);
|
|
438
|
+
} finally {
|
|
439
|
+
db.close();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function assertLegacySchemaMigration() {
|
|
444
|
+
const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-schema-root-${testId}`);
|
|
445
|
+
const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
|
|
446
|
+
try {
|
|
447
|
+
createLegacySchemaDb(legacyDb);
|
|
448
|
+
run(process.execPath, [hccBin, '--root', legacyRoot, 'status', '--peer', 'legacy-check'], { env });
|
|
449
|
+
const db = new DatabaseSync(legacyDb);
|
|
450
|
+
try {
|
|
451
|
+
const peerBindingColumns = new Set(db.prepare('PRAGMA table_info(peer_bindings)').all().map((row) => row.name));
|
|
452
|
+
if (!peerBindingColumns.has('runtime_target')) fail('legacy migration did not add peer_bindings.runtime_target');
|
|
453
|
+
const messageColumns = new Set(db.prepare('PRAGMA table_info(messages)').all().map((row) => row.name));
|
|
454
|
+
for (const column of ['reply_to', 'thread_id']) {
|
|
455
|
+
if (!messageColumns.has(column)) fail(`legacy migration did not add messages.${column}`);
|
|
456
|
+
}
|
|
457
|
+
const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
|
|
458
|
+
for (const column of ['parent_id', 'team_role']) {
|
|
459
|
+
if (!taskColumns.has(column)) fail(`legacy migration did not add tasks.${column}`);
|
|
460
|
+
}
|
|
461
|
+
const message = db.prepare('SELECT id, thread_id FROM messages WHERE body = ?').get('legacy-message');
|
|
462
|
+
if (!message || message.thread_id !== message.id) {
|
|
463
|
+
fail(`legacy message thread_id not backfilled:\n${JSON.stringify(message, null, 2)}`);
|
|
464
|
+
}
|
|
465
|
+
const metaVersion = db.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get()?.value;
|
|
466
|
+
const pragmaVersion = db.prepare('PRAGMA user_version').get().user_version;
|
|
467
|
+
if (metaVersion !== '4' || pragmaVersion !== 4) {
|
|
468
|
+
fail(`schema version not synchronized: meta=${metaVersion} pragma=${pragmaVersion}`);
|
|
469
|
+
}
|
|
470
|
+
const migrations = db.prepare('SELECT version, name FROM schema_migrations ORDER BY version').all();
|
|
471
|
+
if (migrations.length !== 4 || migrations[3].version !== 4 || migrations[3].name !== 'team task hierarchy') {
|
|
472
|
+
fail(`schema migrations history wrong:\n${JSON.stringify(migrations, null, 2)}`);
|
|
473
|
+
}
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
} finally {
|
|
478
|
+
try { fs.rmSync(legacyRoot, { recursive: true, force: true }); } catch {}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function assertRegisteredProjectDbMigration() {
|
|
483
|
+
const otherRoot = path.join(os.tmpdir(), `hcc-reg-registered-legacy-root-${testId}`);
|
|
484
|
+
const otherDb = path.join(otherRoot, '.hello-cc', 'mesh.db');
|
|
485
|
+
const registryFile = path.join(home, '.hello-cc', 'projects.json');
|
|
486
|
+
try {
|
|
487
|
+
createLegacySchemaDb(otherDb);
|
|
488
|
+
fs.mkdirSync(path.dirname(registryFile), { recursive: true });
|
|
489
|
+
fs.writeFileSync(registryFile, JSON.stringify({
|
|
490
|
+
projects: [
|
|
491
|
+
{ root, db: path.join(root, '.hello-cc', 'mesh.db'), name: 'current', last_seen_at: 2 },
|
|
492
|
+
{ root: otherRoot, db: otherDb, name: 'registered-legacy', last_seen_at: 1 }
|
|
493
|
+
]
|
|
494
|
+
}, null, 2));
|
|
495
|
+
hcc(['status', '--peer', 'registered-migration-check']);
|
|
496
|
+
const db = new DatabaseSync(otherDb);
|
|
497
|
+
try {
|
|
498
|
+
const taskColumns = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((row) => row.name));
|
|
499
|
+
if (!taskColumns.has('parent_id') || !taskColumns.has('team_role')) {
|
|
500
|
+
fail(`registered project DB was not migrated:\n${JSON.stringify([...taskColumns], null, 2)}`);
|
|
501
|
+
}
|
|
502
|
+
const version = db.prepare('PRAGMA user_version').get().user_version;
|
|
503
|
+
if (version !== 4) fail(`registered project DB user_version wrong: ${version}`);
|
|
504
|
+
} finally {
|
|
505
|
+
db.close();
|
|
506
|
+
}
|
|
507
|
+
} finally {
|
|
508
|
+
try { fs.rmSync(otherRoot, { recursive: true, force: true }); } catch {}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function assertFutureSchemaMigrationHistoryRejected() {
|
|
513
|
+
const futureRoot = path.join(os.tmpdir(), `hcc-reg-future-schema-root-${testId}`);
|
|
514
|
+
const futureDb = path.join(futureRoot, '.hello-cc', 'mesh.db');
|
|
515
|
+
try {
|
|
516
|
+
fs.mkdirSync(path.dirname(futureDb), { recursive: true });
|
|
517
|
+
const db = new DatabaseSync(futureDb);
|
|
518
|
+
try {
|
|
519
|
+
db.exec(`
|
|
520
|
+
CREATE TABLE meta (
|
|
521
|
+
key TEXT PRIMARY KEY,
|
|
522
|
+
value TEXT NOT NULL
|
|
523
|
+
);
|
|
524
|
+
INSERT INTO meta(key, value) VALUES ('schema_version', '4');
|
|
525
|
+
CREATE TABLE schema_migrations (
|
|
526
|
+
version INTEGER PRIMARY KEY,
|
|
527
|
+
name TEXT NOT NULL,
|
|
528
|
+
applied_at INTEGER NOT NULL
|
|
529
|
+
);
|
|
530
|
+
INSERT INTO schema_migrations(version, name, applied_at)
|
|
531
|
+
VALUES (999, 'future migration', 1);
|
|
532
|
+
`);
|
|
533
|
+
} finally {
|
|
534
|
+
db.close();
|
|
535
|
+
}
|
|
536
|
+
const result = runMaybe(process.execPath, [hccBin, '--root', futureRoot, 'status', '--peer', 'future-schema-check'], { env });
|
|
537
|
+
if (result.status === 0 || !`${result.stdout}\n${result.stderr}`.includes('Database schema version 999 is newer than this hcc (4)')) {
|
|
538
|
+
fail(`future schema migration history was not rejected:\nstdout=${result.stdout}\nstderr=${result.stderr}`);
|
|
539
|
+
}
|
|
540
|
+
} finally {
|
|
541
|
+
try { fs.rmSync(futureRoot, { recursive: true, force: true }); } catch {}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
286
545
|
function assertLegacyBindingRepair() {
|
|
287
546
|
const legacyRoot = path.join(os.tmpdir(), `hcc-reg-legacy-root-${testId}`);
|
|
288
547
|
const legacyDb = path.join(legacyRoot, '.hello-cc', 'mesh.db');
|
|
@@ -403,6 +662,59 @@ function ensureFile(file, expected = null) {
|
|
|
403
662
|
}
|
|
404
663
|
}
|
|
405
664
|
|
|
665
|
+
function assertGuidanceLockPolicy(file) {
|
|
666
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
667
|
+
for (const expected of [
|
|
668
|
+
'Read-only work:',
|
|
669
|
+
'do not require locks',
|
|
670
|
+
'For read-only review, do not acquire file locks',
|
|
671
|
+
'not a final commit-ready verdict',
|
|
672
|
+
'Review and monitoring:',
|
|
673
|
+
"Reviewing another peer's work is a read-only activity",
|
|
674
|
+
'proactively send that',
|
|
675
|
+
'affected file or behavior',
|
|
676
|
+
'Do not silently treat a snapshot review as final approval',
|
|
677
|
+
'Before mutating work:',
|
|
678
|
+
'Before editing or mutating shared resources:',
|
|
679
|
+
'Commit-readiness checks are read-only until staging begins',
|
|
680
|
+
'lock `.git/index` only while staging and'
|
|
681
|
+
]) {
|
|
682
|
+
if (!text.includes(expected)) fail(`guidance lock policy missing ${expected} in ${file}`);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function runtimeUrl(runtime, route, params = {}) {
|
|
687
|
+
const url = new URL(route, runtime.base_url || `http://127.0.0.1:${port}`);
|
|
688
|
+
if (runtime.token) url.searchParams.set('token', runtime.token);
|
|
689
|
+
for (const [key, value] of Object.entries(params)) {
|
|
690
|
+
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
|
|
691
|
+
}
|
|
692
|
+
return url.toString();
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function currentRuntime() {
|
|
696
|
+
return JSON.parse(fs.readFileSync(path.join(root, '.hello-cc', 'runtime.json'), 'utf8'));
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function currentRuntimeUrl(route, params = {}) {
|
|
700
|
+
return runtimeUrl(currentRuntime(), route, params);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function runtimeFetch(route, options = {}, params = {}) {
|
|
704
|
+
const runtime = currentRuntime();
|
|
705
|
+
const headers = { ...(options.headers || {}) };
|
|
706
|
+
if (runtime.token) headers.Authorization = `Bearer ${runtime.token}`;
|
|
707
|
+
return fetch(runtimeUrl(runtime, route, params), { ...options, headers });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function runtimeWsUrl(peer) {
|
|
711
|
+
const runtime = currentRuntime();
|
|
712
|
+
const url = new URL(`/ws/terminal/${encodeURIComponent(peer)}`, runtime.base_url || `http://127.0.0.1:${port}`);
|
|
713
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
714
|
+
if (runtime.token) url.searchParams.set('token', runtime.token);
|
|
715
|
+
return url.toString();
|
|
716
|
+
}
|
|
717
|
+
|
|
406
718
|
async function waitFor(check, label, timeoutMs = 10000) {
|
|
407
719
|
const deadline = Date.now() + timeoutMs;
|
|
408
720
|
while (Date.now() < deadline) {
|
|
@@ -438,8 +750,7 @@ async function waitRuntime() {
|
|
|
438
750
|
await waitFor(async () => {
|
|
439
751
|
if (!fs.existsSync(runtimeFile)) return false;
|
|
440
752
|
try {
|
|
441
|
-
const
|
|
442
|
-
const response = await fetch(`${runtime.base_url}/api/runtime`);
|
|
753
|
+
const response = await runtimeFetch('/api/runtime');
|
|
443
754
|
return response.ok;
|
|
444
755
|
} catch {
|
|
445
756
|
return false;
|
|
@@ -451,7 +762,7 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
451
762
|
await new Promise((resolve, reject) => {
|
|
452
763
|
let sawSnapshot = false;
|
|
453
764
|
let sawMarker = false;
|
|
454
|
-
const ws = new WebSocket(
|
|
765
|
+
const ws = new WebSocket(runtimeWsUrl(peer));
|
|
455
766
|
const timer = setTimeout(() => reject(new Error(`${peer} websocket timeout`)), 5000);
|
|
456
767
|
ws.on('open', () => {
|
|
457
768
|
const result = hccMaybe(['inject', peer, `echo ${marker}`]);
|
|
@@ -476,7 +787,7 @@ async function expectWebSocketMarker(peer, marker) {
|
|
|
476
787
|
async function expectResizeReplaceSnapshot(peer, marker) {
|
|
477
788
|
await new Promise((resolve, reject) => {
|
|
478
789
|
let sawSnapshot = false;
|
|
479
|
-
const ws = new WebSocket(
|
|
790
|
+
const ws = new WebSocket(runtimeWsUrl(peer));
|
|
480
791
|
const timer = setTimeout(() => reject(new Error(`${peer} resize replace timeout`)), 5000);
|
|
481
792
|
ws.on('open', () => {
|
|
482
793
|
const result = hccMaybe(['inject', peer, `echo ${marker}`]);
|
|
@@ -498,6 +809,39 @@ async function expectResizeReplaceSnapshot(peer, marker) {
|
|
|
498
809
|
});
|
|
499
810
|
}
|
|
500
811
|
|
|
812
|
+
async function expectWebSocketInputVisible(peer, marker) {
|
|
813
|
+
await new Promise((resolve, reject) => {
|
|
814
|
+
let sawSnapshot = false;
|
|
815
|
+
let sent = false;
|
|
816
|
+
let sawMarkerAfterInput = false;
|
|
817
|
+
let sawFrameAfterInput = false;
|
|
818
|
+
const ws = new WebSocket(runtimeWsUrl(peer));
|
|
819
|
+
const timer = setTimeout(() => reject(new Error(`${peer} websocket input visibility timeout`)), 5000);
|
|
820
|
+
ws.on('message', (raw) => {
|
|
821
|
+
const msg = JSON.parse(String(raw));
|
|
822
|
+
const data = String(msg.data || '');
|
|
823
|
+
if (msg.type === 'snapshot') {
|
|
824
|
+
sawSnapshot = true;
|
|
825
|
+
if (!sent) {
|
|
826
|
+
sent = true;
|
|
827
|
+
ws.send(JSON.stringify({ type: 'input', data: `echo ${marker}\r` }));
|
|
828
|
+
}
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (sent && ['data', 'replace'].includes(msg.type)) {
|
|
832
|
+
sawFrameAfterInput = true;
|
|
833
|
+
if (data.includes(marker)) sawMarkerAfterInput = true;
|
|
834
|
+
}
|
|
835
|
+
if (sawSnapshot && sawFrameAfterInput && sawMarkerAfterInput) {
|
|
836
|
+
clearTimeout(timer);
|
|
837
|
+
ws.close();
|
|
838
|
+
resolve();
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
ws.on('error', reject);
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
501
845
|
function tmuxStreamNodes() {
|
|
502
846
|
const dir = path.join(root, '.hello-cc', 'bufs');
|
|
503
847
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -640,24 +984,92 @@ async function setupRegression() {
|
|
|
640
984
|
}
|
|
641
985
|
await stopRuntime();
|
|
642
986
|
assertPeerBindingUniqueConstraints();
|
|
987
|
+
assertLegacySchemaMigration();
|
|
988
|
+
assertRegisteredProjectDbMigration();
|
|
989
|
+
assertFutureSchemaMigrationHistoryRejected();
|
|
643
990
|
assertLegacyBindingRepair();
|
|
644
991
|
|
|
645
|
-
const
|
|
992
|
+
const tokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
993
|
+
const tokenMatch = tokenOutput.match(/^pid:\s*(\d+)/m);
|
|
994
|
+
if (!tokenMatch) fail(`token web did not print background pid:\n${tokenOutput}`);
|
|
995
|
+
runtimePid = Number.parseInt(tokenMatch[1], 10);
|
|
996
|
+
if (!tokenOutput.includes('token=') || !tokenOutput.includes('open: http://<machine-ip>:')) {
|
|
997
|
+
fail(`default web output did not include remote token URL:\n${tokenOutput}`);
|
|
998
|
+
}
|
|
999
|
+
await waitRuntime();
|
|
1000
|
+
const tokenRuntime = currentRuntime();
|
|
1001
|
+
if (tokenRuntime.host !== '0.0.0.0' || !tokenRuntime.token || tokenRuntime.token.length < 24) {
|
|
1002
|
+
fail(`default web runtime did not store remote token data:\n${JSON.stringify(tokenRuntime, null, 2)}`);
|
|
1003
|
+
}
|
|
1004
|
+
const unauthorizedResponse = await fetch(`${tokenRuntime.base_url}/api/runtime`);
|
|
1005
|
+
if (unauthorizedResponse.status !== 401) fail(`default web API allowed missing token: ${unauthorizedResponse.status}`);
|
|
1006
|
+
const tokenResponse = await runtimeFetch('/api/runtime');
|
|
1007
|
+
if (!tokenResponse.ok) fail(`default web API rejected runtime token: ${tokenResponse.status}`);
|
|
1008
|
+
const tokenFile = path.join(home, '.hello-cc', 'web-token');
|
|
1009
|
+
ensureFile(tokenFile, tokenRuntime.token);
|
|
1010
|
+
fs.rmSync(tokenFile, { force: true });
|
|
1011
|
+
const existingTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
1012
|
+
if (!existingTokenOutput.includes('web already running in background')) {
|
|
1013
|
+
fail(`web did not reuse existing token runtime:\n${existingTokenOutput}`);
|
|
1014
|
+
}
|
|
1015
|
+
ensureFile(tokenFile, tokenRuntime.token);
|
|
1016
|
+
await stopRuntime();
|
|
1017
|
+
|
|
1018
|
+
const stableTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
1019
|
+
const stableTokenMatch = stableTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
1020
|
+
if (!stableTokenMatch) fail(`stable-token web did not print background pid:\n${stableTokenOutput}`);
|
|
1021
|
+
runtimePid = Number.parseInt(stableTokenMatch[1], 10);
|
|
1022
|
+
await waitRuntime();
|
|
1023
|
+
const stableTokenRuntime = currentRuntime();
|
|
1024
|
+
if (stableTokenRuntime.token !== tokenRuntime.token) {
|
|
1025
|
+
fail(`default web token changed across restart:\nfirst=${tokenRuntime.token}\nsecond=${stableTokenRuntime.token}`);
|
|
1026
|
+
}
|
|
1027
|
+
await stopRuntime();
|
|
1028
|
+
|
|
1029
|
+
const fixedToken = `fixed-token-${testId}`;
|
|
1030
|
+
const fixedTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance'], {
|
|
1031
|
+
env: { ...env, HCC_WEB_TOKEN: fixedToken }
|
|
1032
|
+
});
|
|
1033
|
+
const fixedTokenMatch = fixedTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
1034
|
+
if (!fixedTokenMatch) fail(`fixed-token web did not print background pid:\n${fixedTokenOutput}`);
|
|
1035
|
+
runtimePid = Number.parseInt(fixedTokenMatch[1], 10);
|
|
1036
|
+
await waitRuntime();
|
|
1037
|
+
const fixedTokenRuntime = currentRuntime();
|
|
1038
|
+
if (fixedTokenRuntime.token !== fixedToken || !fixedTokenOutput.includes(`token=${encodeURIComponent(fixedToken)}`)) {
|
|
1039
|
+
fail(`explicit stable token was not used:\n${fixedTokenOutput}\n${JSON.stringify(fixedTokenRuntime, null, 2)}`);
|
|
1040
|
+
}
|
|
1041
|
+
await stopRuntime();
|
|
1042
|
+
|
|
1043
|
+
const persistedFixedOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-discover', '--no-guidance']);
|
|
1044
|
+
const persistedFixedMatch = persistedFixedOutput.match(/^pid:\s*(\d+)/m);
|
|
1045
|
+
if (!persistedFixedMatch) fail(`persisted fixed-token web did not print background pid:\n${persistedFixedOutput}`);
|
|
1046
|
+
runtimePid = Number.parseInt(persistedFixedMatch[1], 10);
|
|
1047
|
+
await waitRuntime();
|
|
1048
|
+
const persistedFixedRuntime = currentRuntime();
|
|
1049
|
+
if (persistedFixedRuntime.token !== fixedToken) {
|
|
1050
|
+
fail(`explicit stable token was not persisted:\n${JSON.stringify(persistedFixedRuntime, null, 2)}`);
|
|
1051
|
+
}
|
|
1052
|
+
await stopRuntime();
|
|
1053
|
+
|
|
1054
|
+
const noTokenOutput = hcc(['web', '--host', '0.0.0.0', '--port', String(port), '--no-token', '--no-discover', '--no-guidance']);
|
|
646
1055
|
const noTokenMatch = noTokenOutput.match(/^pid:\s*(\d+)/m);
|
|
647
|
-
if (!noTokenMatch) fail(`no-token web did not print background pid:\n${noTokenOutput}`);
|
|
1056
|
+
if (!noTokenMatch) fail(`explicit no-token web did not print background pid:\n${noTokenOutput}`);
|
|
648
1057
|
runtimePid = Number.parseInt(noTokenMatch[1], 10);
|
|
649
|
-
if (noTokenOutput.includes('token=')
|
|
650
|
-
fail(`
|
|
1058
|
+
if (noTokenOutput.includes('token=')) {
|
|
1059
|
+
fail(`explicit no-token web output included token:\n${noTokenOutput}`);
|
|
651
1060
|
}
|
|
652
1061
|
await waitRuntime();
|
|
653
|
-
const noTokenRuntime =
|
|
654
|
-
if (noTokenRuntime.token
|
|
655
|
-
fail(`default web runtime unexpectedly stored token data:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
|
|
656
|
-
}
|
|
1062
|
+
const noTokenRuntime = currentRuntime();
|
|
1063
|
+
if (noTokenRuntime.token) fail(`explicit no-token runtime stored token:\n${JSON.stringify(noTokenRuntime, null, 2)}`);
|
|
657
1064
|
const noTokenResponse = await fetch(`${noTokenRuntime.base_url}/api/runtime`);
|
|
658
|
-
if (!noTokenResponse.ok) fail(`
|
|
1065
|
+
if (!noTokenResponse.ok) fail(`explicit no-token web API required token: ${noTokenResponse.status}`);
|
|
659
1066
|
await stopRuntime();
|
|
660
1067
|
|
|
1068
|
+
const conflictingToken = hccMaybe(['web', '--local', '--port', String(port), '--token', 'abc', '--no-token', '--no-discover', '--no-guidance']);
|
|
1069
|
+
if (conflictingToken.status === 0 || !String(conflictingToken.stderr || conflictingToken.stdout).includes('--no-token cannot be combined')) {
|
|
1070
|
+
fail(`web accepted conflicting --token and --no-token:\n${conflictingToken.stdout}\n${conflictingToken.stderr}`);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
661
1073
|
const childDir = path.join(root, 'packages', 'child');
|
|
662
1074
|
fs.mkdirSync(childDir, { recursive: true });
|
|
663
1075
|
const childFindRoot = hccFrom(['find-root'], childDir).trim();
|
|
@@ -714,9 +1126,71 @@ async function dbWorkflow() {
|
|
|
714
1126
|
if (!inbox.includes('please review')) fail('inbox did not include message');
|
|
715
1127
|
const msgMatch = inbox.match(/^#(\d+)/m);
|
|
716
1128
|
if (!msgMatch) fail(`cannot parse message id: ${inbox}`);
|
|
717
|
-
|
|
1129
|
+
const msgId = msgMatch[1];
|
|
1130
|
+
const reply = hcc(['msg', 'reply', '--from', 'claude-a', '--id', msgId, '--body', 'review reply']);
|
|
1131
|
+
const replyMatch = reply.match(/sent reply #(\d+) to #(\d+)/);
|
|
1132
|
+
if (!replyMatch || replyMatch[2] !== msgId) fail(`cannot parse reply output: ${reply}`);
|
|
1133
|
+
const thread = hcc(['msg', 'thread', '--id', msgId]);
|
|
1134
|
+
if (!thread.includes(`thread #${msgId}`) || !thread.includes('please review') || !thread.includes('review reply')) {
|
|
1135
|
+
fail(`thread output missing original or reply:\n${thread}`);
|
|
1136
|
+
}
|
|
1137
|
+
const replyInbox = hcc(['msg', 'inbox', '--peer', 'codex-a']);
|
|
1138
|
+
if (!replyInbox.includes('review reply') || !replyInbox.includes(`#${msgId}`)) {
|
|
1139
|
+
fail(`reply did not reach original sender inbox with thread context:\n${replyInbox}`);
|
|
1140
|
+
}
|
|
1141
|
+
const stateBeforeAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/router']);
|
|
1142
|
+
if (stateBeforeAck.automation?.schema_version !== 1 || stateBeforeAck.automation.phase !== 'reply_message') {
|
|
1143
|
+
fail(`state automation did not prioritize unread reply:\n${JSON.stringify(stateBeforeAck, null, 2)}`);
|
|
1144
|
+
}
|
|
1145
|
+
if (stateBeforeAck.automation.next_action.kind !== 'msg.reply' || !stateBeforeAck.automation.next_action.argv.includes(replyMatch[1])) {
|
|
1146
|
+
fail(`state next action did not target unread reply:\n${JSON.stringify(stateBeforeAck.automation, null, 2)}`);
|
|
1147
|
+
}
|
|
1148
|
+
const timelineIds = new Set((stateBeforeAck.timeline || []).map((item) => item.id));
|
|
1149
|
+
if (!timelineIds.has(`message:${msgId}`) || !timelineIds.has(`message:${replyMatch[1]}`)) {
|
|
1150
|
+
fail(`state timeline missing message thread entries:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
|
|
1151
|
+
}
|
|
1152
|
+
if ([...(stateBeforeAck.timeline || [])].some((item) => item.kind === 'message.sent' || item.kind === 'message.ack')) {
|
|
1153
|
+
fail(`state timeline includes noisy message events:\n${JSON.stringify(stateBeforeAck.timeline, null, 2)}`);
|
|
1154
|
+
}
|
|
1155
|
+
hcc(['msg', 'ack', '--peer', 'claude-a', '--id', msgId]);
|
|
1156
|
+
hcc(['msg', 'ack', '--peer', 'codex-a', '--id', replyMatch[1]]);
|
|
1157
|
+
const stateAfterAck = hccJson(['state', '--peer', 'codex-a', '--resource', 'src/new-lock']);
|
|
1158
|
+
if (stateAfterAck.automation.next_action.kind !== 'lock.acquire') {
|
|
1159
|
+
fail(`state automation did not suggest lock acquire after ack:\n${JSON.stringify(stateAfterAck.automation, null, 2)}`);
|
|
1160
|
+
}
|
|
1161
|
+
const queuedTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-a', '--title', 'queued while busy']);
|
|
1162
|
+
const queuedTaskMatch = queuedTask.match(/created task #(\d+):/);
|
|
1163
|
+
if (!queuedTaskMatch) fail(`cannot parse queued task id: ${queuedTask}`);
|
|
1164
|
+
const queuedTaskId = queuedTaskMatch[1];
|
|
1165
|
+
const busyState = hccJson(['state', '--peer', 'codex-a']);
|
|
1166
|
+
if (String(busyState.automation.current_task?.id) !== String(taskId)) {
|
|
1167
|
+
fail(`state did not preserve current task while another task was assigned:\n${JSON.stringify(busyState.automation, null, 2)}`);
|
|
1168
|
+
}
|
|
1169
|
+
if (['task.claim', 'task.next', 'msg.inbox'].includes(busyState.automation.next_action.kind)) {
|
|
1170
|
+
fail(`state let a new assigned task interrupt current work:\n${JSON.stringify(busyState.automation, null, 2)}`);
|
|
1171
|
+
}
|
|
1172
|
+
const busyHookPayload = JSON.stringify({ session_id: 'codex-busy-session', cwd: root, hook_event_name: 'UserPromptSubmit', prompt: 'new user prompt while busy' });
|
|
1173
|
+
const busyHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: { ...env, HCC_PEER: 'codex-a' }, input: busyHookPayload }), 'UserPromptSubmit');
|
|
1174
|
+
if (!busyHook.includes('[hello-cc current task]') || !busyHook.includes(`#${taskId} running`)) {
|
|
1175
|
+
fail(`hook did not preserve current task while another task was assigned:\n${busyHook}`);
|
|
1176
|
+
}
|
|
1177
|
+
if (busyHook.includes(`hcc task claim --peer codex-a --id ${queuedTaskId}`)) {
|
|
1178
|
+
fail(`hook suggested claiming a new task while current task was active:\n${busyHook}`);
|
|
1179
|
+
}
|
|
1180
|
+
const nextAgain = hcc(['task', 'next', '--peer', 'codex-a']);
|
|
1181
|
+
if (!nextAgain.includes(`current task #${taskId}`)) {
|
|
1182
|
+
fail(`task next did not preserve current task:\n${nextAgain}`);
|
|
1183
|
+
}
|
|
1184
|
+
hcc(['task', 'update', '--peer', 'human', '--id', queuedTaskId, '--status', 'abandoned', '--summary', 'queued task cleanup']);
|
|
718
1185
|
hcc(['handoff', 'create', '--from', 'codex-a', '--to', 'claude-a', '--task', taskId, '--summary', 'handoff summary', '--tests', 'full script', '--risks', 'none']);
|
|
719
1186
|
if (!hcc(['handoff', 'list', '--task', taskId]).includes('handoff summary')) fail('handoff missing');
|
|
1187
|
+
const stateAfterHandoff = hccJson(['state', '--peer', 'claude-a']);
|
|
1188
|
+
if (!(stateAfterHandoff.timeline || []).some((item) => item.id.startsWith('handoff:') && item.text.includes('handoff summary'))) {
|
|
1189
|
+
fail(`state timeline missing handoff item:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
|
|
1190
|
+
}
|
|
1191
|
+
if ((stateAfterHandoff.timeline || []).some((item) => item.kind === 'handoff.created')) {
|
|
1192
|
+
fail(`state timeline includes noisy handoff event:\n${JSON.stringify(stateAfterHandoff.timeline, null, 2)}`);
|
|
1193
|
+
}
|
|
720
1194
|
if (!hcc(['status', '--peer', 'codex-a']).includes('codex-a')) fail('status missing peer');
|
|
721
1195
|
hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/router']);
|
|
722
1196
|
hcc(['task', 'done', '--peer', 'codex-a', '--id', taskId, '--summary', 'done']);
|
|
@@ -725,6 +1199,77 @@ async function dbWorkflow() {
|
|
|
725
1199
|
if (!hasTask(hccJson(['task', 'list', '--all']), taskId)) fail(`done task missing from --all list: #${taskId}`);
|
|
726
1200
|
if (!hasTask(hccJson(['task', 'list', '--status', 'done']), taskId)) fail(`done task missing from --status done list: #${taskId}`);
|
|
727
1201
|
|
|
1202
|
+
const teamParent = hcc([
|
|
1203
|
+
'task', 'create',
|
|
1204
|
+
'--from', 'human',
|
|
1205
|
+
'--to', 'codex-team-captain',
|
|
1206
|
+
'--title', 'parallel release cleanup',
|
|
1207
|
+
'--body', '- Update docs\n- Add regression\n- Verify migration'
|
|
1208
|
+
]);
|
|
1209
|
+
const teamParentMatch = teamParent.match(/created task #(\d+):/);
|
|
1210
|
+
if (!teamParentMatch) fail(`cannot parse team parent id: ${teamParent}`);
|
|
1211
|
+
const teamParentId = teamParentMatch[1];
|
|
1212
|
+
hcc(['task', 'claim', '--peer', 'codex-team-captain', '--id', teamParentId]);
|
|
1213
|
+
hcc(['task', 'update', '--peer', 'codex-team-captain', '--id', teamParentId, '--status', 'running', '--summary', 'team captain']);
|
|
1214
|
+
const teamState = hccJson(['state', '--peer', 'codex-team-captain']);
|
|
1215
|
+
if (teamState.automation.phase !== 'team_plan' || teamState.automation.next_action.kind !== 'team.plan') {
|
|
1216
|
+
fail(`state did not suggest team plan for splittable task:\n${JSON.stringify(teamState.automation, null, 2)}`);
|
|
1217
|
+
}
|
|
1218
|
+
const teamPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
|
|
1219
|
+
if (!teamPlan.includes('team plan for task') || !teamPlan.includes('Update docs')) {
|
|
1220
|
+
fail(`team plan output wrong:\n${teamPlan}`);
|
|
1221
|
+
}
|
|
1222
|
+
const kindWorkerPlan = hcc(['team', 'plan', '--from-task', teamParentId, '--item', 'docs:Docs slot', '--item', 'tests:Tests slot', '--workers', 'codex:2']);
|
|
1223
|
+
if (!kindWorkerPlan.includes(`codex-team-${teamParentId}-1`) || !kindWorkerPlan.includes(`codex-team-${teamParentId}-2`)) {
|
|
1224
|
+
fail(`team workers kind-count syntax was not expanded:\n${kindWorkerPlan}`);
|
|
1225
|
+
}
|
|
1226
|
+
const childrenBeforeStart = withMeshDb((db) => db.prepare('SELECT COUNT(*) AS n FROM tasks WHERE parent_id = ?').get(teamParentId).n);
|
|
1227
|
+
if (childrenBeforeStart !== 0) fail(`team plan created children: ${childrenBeforeStart}`);
|
|
1228
|
+
const teamStart = hccJson(['team', 'start', '--from', 'codex-team-captain', '--from-task', teamParentId, '--item', 'docs:Update docs', '--item', 'tests:Add regression', '--workers', 'codex-worker-a,claude-worker-a']);
|
|
1229
|
+
if (teamStart.children.length !== 2) {
|
|
1230
|
+
fail(`team start created wrong child count:\n${JSON.stringify(teamStart, null, 2)}`);
|
|
1231
|
+
}
|
|
1232
|
+
const teamRows = withMeshDb((db) => db.prepare(`
|
|
1233
|
+
SELECT id, title, assignee, parent_id, team_role
|
|
1234
|
+
FROM tasks
|
|
1235
|
+
WHERE parent_id = ?
|
|
1236
|
+
ORDER BY id
|
|
1237
|
+
`).all(teamParentId));
|
|
1238
|
+
if (teamRows.length !== 2 ||
|
|
1239
|
+
teamRows[0].assignee !== 'codex-worker-a' ||
|
|
1240
|
+
teamRows[1].assignee !== 'claude-worker-a' ||
|
|
1241
|
+
teamRows.some((row) => String(row.parent_id) !== String(teamParentId) || !row.team_role)) {
|
|
1242
|
+
fail(`team child rows wrong:\n${JSON.stringify(teamRows, null, 2)}`);
|
|
1243
|
+
}
|
|
1244
|
+
const duplicateTeamStart = hccMaybe(['team', 'start', '--from-task', teamParentId]);
|
|
1245
|
+
if (duplicateTeamStart.status === 0 || !String(duplicateTeamStart.stderr || duplicateTeamStart.stdout).includes('already has')) {
|
|
1246
|
+
fail(`duplicate team start was not rejected:\n${duplicateTeamStart.stdout}\n${duplicateTeamStart.stderr}`);
|
|
1247
|
+
}
|
|
1248
|
+
const teamStatus = hcc(['team', 'status', '--task', teamParentId]);
|
|
1249
|
+
if (!teamStatus.includes('subtasks: 2') || !teamStatus.includes('codex-worker-a')) {
|
|
1250
|
+
fail(`team status output wrong:\n${teamStatus}`);
|
|
1251
|
+
}
|
|
1252
|
+
for (const row of teamRows) {
|
|
1253
|
+
hcc(['task', 'update', '--peer', 'human', '--force', '--id', String(row.id), '--status', 'abandoned', '--summary', 'team regression cleanup']);
|
|
1254
|
+
}
|
|
1255
|
+
hcc(['task', 'done', '--peer', 'codex-team-captain', '--id', teamParentId, '--summary', 'team regression done']);
|
|
1256
|
+
|
|
1257
|
+
const conflictTask = hcc(['task', 'create', '--from', 'human', '--to', 'codex-b', '--title', 'conflict automation task']);
|
|
1258
|
+
const conflictTaskMatch = conflictTask.match(/created task #(\d+):/);
|
|
1259
|
+
if (!conflictTaskMatch) fail(`cannot parse conflict task id: ${conflictTask}`);
|
|
1260
|
+
const conflictTaskId = conflictTaskMatch[1];
|
|
1261
|
+
hcc(['task', 'claim', '--peer', 'codex-b', '--id', conflictTaskId]);
|
|
1262
|
+
hcc(['lock', 'acquire', '--peer', 'codex-a', '--resource', 'src/conflict', '--ttl', '60', '--reason', 'held by other peer']);
|
|
1263
|
+
const conflictState = hccJson(['state', '--peer', 'codex-b', '--resource', 'src/conflict']);
|
|
1264
|
+
if (conflictState.automation.phase !== 'coordinate_lock' || conflictState.automation.next_action.kind !== 'msg.send') {
|
|
1265
|
+
fail(`state automation did not suggest lock coordination:\n${JSON.stringify(conflictState.automation, null, 2)}`);
|
|
1266
|
+
}
|
|
1267
|
+
if (conflictState.automation.next_action.argv.includes('--force')) {
|
|
1268
|
+
fail(`state automation suggested forcing a lock:\n${JSON.stringify(conflictState.automation, null, 2)}`);
|
|
1269
|
+
}
|
|
1270
|
+
hcc(['lock', 'release', '--peer', 'codex-a', '--resource', 'src/conflict']);
|
|
1271
|
+
hcc(['task', 'done', '--peer', 'codex-b', '--id', conflictTaskId, '--summary', 'conflict done']);
|
|
1272
|
+
|
|
728
1273
|
const abandoned = hcc(['task', 'create', '--from', 'human', '--title', 'abandoned regression task']);
|
|
729
1274
|
const abandonedMatch = abandoned.match(/created task #(\d+):/);
|
|
730
1275
|
if (!abandonedMatch) fail(`cannot parse abandoned task id: ${abandoned}`);
|
|
@@ -753,9 +1298,12 @@ async function dbWorkflow() {
|
|
|
753
1298
|
if (!firstHook.includes('[hello-cc known peers]') || !firstHook.includes('do not say sessions are isolated')) {
|
|
754
1299
|
fail(`UserPromptSubmit hook missing strong cross-session instruction:\n${firstHook}`);
|
|
755
1300
|
}
|
|
756
|
-
if (!firstHook.includes('hcc task list') || !firstHook.includes('hook-only-message')) {
|
|
1301
|
+
if (!firstHook.includes('hcc task list') || !firstHook.includes('hcc msg reply --id <message-id>') || !firstHook.includes('hook-only-message')) {
|
|
757
1302
|
fail(`UserPromptSubmit hook missing instructions or unread message:\n${firstHook}`);
|
|
758
1303
|
}
|
|
1304
|
+
if (!firstHook.includes('[hello-cc next action]') || !firstHook.includes('phase: reply_message') || !firstHook.includes('hcc msg reply')) {
|
|
1305
|
+
fail(`UserPromptSubmit hook missing executable next action:\n${firstHook}`);
|
|
1306
|
+
}
|
|
759
1307
|
const secondHook = hookContext(hcc(['hook', 'userpromptsubmit'], { env: hookEnv, input: hookPayload }), 'UserPromptSubmit');
|
|
760
1308
|
if (!secondHook.includes(`#${hookTaskId} running`)) {
|
|
761
1309
|
fail(`UserPromptSubmit hook stopped showing open task after first read:\n${secondHook}`);
|
|
@@ -763,6 +1311,9 @@ async function dbWorkflow() {
|
|
|
763
1311
|
if (secondHook.includes('hook-only-message')) {
|
|
764
1312
|
fail(`UserPromptSubmit hook repeated acked unread message:\n${secondHook}`);
|
|
765
1313
|
}
|
|
1314
|
+
if (!secondHook.includes('[hello-cc next action]')) {
|
|
1315
|
+
fail(`UserPromptSubmit hook dropped next action after ack:\n${secondHook}`);
|
|
1316
|
+
}
|
|
766
1317
|
const sessionHookPayload = JSON.stringify({ session_id: 'claude-hook-session', cwd: root, hook_event_name: 'SessionStart', source: 'resume' });
|
|
767
1318
|
const sessionHook = hookContext(hcc(['hook', 'sessionstart'], { env: hookEnv, input: sessionHookPayload }), 'SessionStart');
|
|
768
1319
|
if (!sessionHook.includes(`#${hookTaskId} running`) || sessionHook.includes('hook-only-message')) {
|
|
@@ -784,12 +1335,22 @@ async function dbWorkflow() {
|
|
|
784
1335
|
const autoTaskId = autoTaskMatch[1];
|
|
785
1336
|
hcc(['task', 'claim', '--id', autoTaskId], { env: autoEnv });
|
|
786
1337
|
hcc(['task', 'update', '--id', autoTaskId, '--status', 'running', '--summary', 'auto running'], { env: autoEnv });
|
|
1338
|
+
const autoState = hccJson(['state'], { env: autoEnv });
|
|
1339
|
+
if (autoState.automation.peer?.id !== autoPeer) {
|
|
1340
|
+
fail(`auto state used wrong peer: ${autoPeer}\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1341
|
+
}
|
|
1342
|
+
if (String(autoState.automation.current_task?.id) !== String(autoTaskId)) {
|
|
1343
|
+
fail(`auto state lost current task #${autoTaskId}:\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1344
|
+
}
|
|
1345
|
+
if (autoState.automation.next_action.kind === 'task.next') {
|
|
1346
|
+
fail(`auto state suggested task.next while current task was active:\n${JSON.stringify(autoState.automation, null, 2)}`);
|
|
1347
|
+
}
|
|
787
1348
|
hcc(['lock', 'acquire', '--resource', 'auto/resource', '--task', autoTaskId, '--ttl', '60'], { env: autoEnv });
|
|
788
1349
|
hcc(['lock', 'renew', '--resource', 'auto/resource', '--ttl', '60'], { env: autoEnv });
|
|
789
1350
|
hcc(['handoff', 'create', '--summary', 'auto handoff', '--tests', 'auto test', '--risks', 'none'], { env: autoEnv });
|
|
790
1351
|
hcc(['lock', 'release', '--resource', 'auto/resource'], { env: autoEnv });
|
|
791
1352
|
hcc(['task', 'done', '--id', autoTaskId, '--summary', 'auto done'], { env: autoEnv });
|
|
792
|
-
const autoEvents = hcc(['event', 'tail', '--limit', '
|
|
1353
|
+
const autoEvents = hcc(['event', 'tail', '--limit', '200']);
|
|
793
1354
|
if (!autoEvents.includes('peer.auto_joined') || !autoEvents.includes(autoPeer)) {
|
|
794
1355
|
fail(`auto join event missing for ${autoPeer}:\n${autoEvents}`);
|
|
795
1356
|
}
|
|
@@ -818,7 +1379,7 @@ async function multiProjectWebWorkflow() {
|
|
|
818
1379
|
fail(`second project runtime did not point at global runtime:\n${JSON.stringify(otherRuntime, null, 2)}`);
|
|
819
1380
|
}
|
|
820
1381
|
|
|
821
|
-
const projectsResponse = await
|
|
1382
|
+
const projectsResponse = await runtimeFetch('/api/projects', {}, { root: otherRoot });
|
|
822
1383
|
const projects = await projectsResponse.json();
|
|
823
1384
|
if (!projectsResponse.ok) fail(`projects API failed: ${JSON.stringify(projects)}`);
|
|
824
1385
|
const roots = new Set((projects.projects || []).map((p) => p.root));
|
|
@@ -826,23 +1387,140 @@ async function multiProjectWebWorkflow() {
|
|
|
826
1387
|
fail(`projects API did not include both roots:\n${JSON.stringify(projects, null, 2)}`);
|
|
827
1388
|
}
|
|
828
1389
|
|
|
829
|
-
const detectedResponse = await
|
|
1390
|
+
const detectedResponse = await runtimeFetch('/api/detected', {}, { root });
|
|
830
1391
|
const detectedJson = await detectedResponse.json();
|
|
831
1392
|
if (!detectedResponse.ok || typeof detectedJson.active_peer_ttl !== 'number' || typeof detectedJson.now !== 'number' || !Array.isArray(detectedJson.detected)) {
|
|
832
1393
|
fail(`detected API did not return liveness metadata:\n${JSON.stringify(detectedJson, null, 2)}`);
|
|
833
1394
|
}
|
|
834
1395
|
|
|
835
|
-
const htmlResponse = await fetch(
|
|
1396
|
+
const htmlResponse = await fetch(currentRuntimeUrl('/'));
|
|
836
1397
|
const html = await htmlResponse.text();
|
|
837
1398
|
for (const forbidden of ['Alias optional', 'Role tag', 'Command<input', 'Working directory', 'commandbar', 'lineInput', 'Send text to active terminal']) {
|
|
838
1399
|
if (html.includes(forbidden)) fail(`web form still exposes ${forbidden}`);
|
|
839
1400
|
}
|
|
840
|
-
|
|
841
|
-
|
|
1401
|
+
for (const expected of [
|
|
1402
|
+
'id="projectSelect"',
|
|
1403
|
+
'id="projectPath"',
|
|
1404
|
+
'id="addProjectBtn"',
|
|
1405
|
+
'id="startForm"',
|
|
1406
|
+
'id="kind"',
|
|
1407
|
+
'id="sessionKindFilter"',
|
|
1408
|
+
'id="sessions"'
|
|
1409
|
+
]) {
|
|
1410
|
+
if (!html.includes(expected)) fail(`web form missing simplified project/session control: ${expected}`);
|
|
1411
|
+
}
|
|
1412
|
+
for (const expected of [
|
|
1413
|
+
'id="langSelect"',
|
|
1414
|
+
"localStorage.getItem('hcc.lang')",
|
|
1415
|
+
"localStorage.setItem('hcc.lang', lang)",
|
|
1416
|
+
"document.documentElement.lang = lang === 'zh' ? 'zh-CN' : 'en'",
|
|
1417
|
+
"zh: {",
|
|
1418
|
+
"language: '语言'",
|
|
1419
|
+
"projectState: '项目状态'",
|
|
1420
|
+
"noSessionSelected: '未选择会话'",
|
|
1421
|
+
"sendMessage: '发送消息'",
|
|
1422
|
+
"peers: '协作方'",
|
|
1423
|
+
"noPeers: '没有协作方。'",
|
|
1424
|
+
"'status.active': '活跃'",
|
|
1425
|
+
"data-i18n=\"language\"",
|
|
1426
|
+
"data-i18n-placeholder=\"projectPathPlaceholder\"",
|
|
1427
|
+
"data-i18n-title=\"collapseSidebar\"",
|
|
1428
|
+
"function applyLanguage()",
|
|
1429
|
+
"function tr(key",
|
|
1430
|
+
"function statusText(status)",
|
|
1431
|
+
"function sessionMetaText(session)",
|
|
1432
|
+
"connText('attached')",
|
|
1433
|
+
"connText('coordinationOnly')",
|
|
1434
|
+
"tr('noActiveSessions')",
|
|
1435
|
+
"tr('nextAction')",
|
|
1436
|
+
"tr('detectedSession')",
|
|
1437
|
+
"tr('messageBodyPlaceholder')",
|
|
1438
|
+
"activeType === 'detected' && activeDetected",
|
|
1439
|
+
"const draft = document.getElementById('detMsg')?.value || ''",
|
|
1440
|
+
"statusText(state.label)",
|
|
1441
|
+
"statusText(automation.phase || 'idle')",
|
|
1442
|
+
"tr('runtime')"
|
|
1443
|
+
]) {
|
|
1444
|
+
if (!html.includes(expected)) fail(`web UI missing i18n support: ${expected}`);
|
|
1445
|
+
}
|
|
1446
|
+
if (!html.includes('id="startMode"') || !html.includes('id="resumeArg"') || !html.includes('syncStartModeOptions') || !html.includes("mode === 'resume'")) {
|
|
1447
|
+
fail('web form missing provider resume controls');
|
|
1448
|
+
}
|
|
1449
|
+
for (const expected of [
|
|
1450
|
+
'--left-width',
|
|
1451
|
+
'--right-width',
|
|
1452
|
+
'hcc.sidebar.left.width',
|
|
1453
|
+
'hcc.sidebar.right.width',
|
|
1454
|
+
'class="edge-resizer edge-resizer-left"',
|
|
1455
|
+
'class="edge-resizer edge-resizer-right"',
|
|
1456
|
+
'id="resizeLeft"',
|
|
1457
|
+
'id="resizeRight"',
|
|
1458
|
+
'role="separator"',
|
|
1459
|
+
'aria-orientation="vertical"',
|
|
1460
|
+
'bindSideHandle(resizeLeftHandle',
|
|
1461
|
+
'bindSideHandle(resizeRightHandle',
|
|
1462
|
+
'bindSideHandle(toggleLeftBtn',
|
|
1463
|
+
'bindSideHandle(toggleRightBtn',
|
|
1464
|
+
'setPointerCapture',
|
|
1465
|
+
'sideIsCollapsed(opposite) ? 0',
|
|
1466
|
+
'Math.abs(delta) <= 3',
|
|
1467
|
+
"localStorage.setItem('hcc.collapse.' + side, on ? '1' : '0');\n applySideWidths();",
|
|
1468
|
+
'cursor: col-resize'
|
|
1469
|
+
]) {
|
|
1470
|
+
if (!html.includes(expected)) fail(`web layout missing resizable sidebar support: ${expected}`);
|
|
842
1471
|
}
|
|
843
|
-
if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes('
|
|
1472
|
+
if (!html.includes('state-card') || !html.includes('peerStateView') || !html.includes('savedCardScroll') || !html.includes('lastStateRoot') || !html.includes("stateCardHtml('peers'")) {
|
|
844
1473
|
fail('web state panel missing scrollable peer state UI');
|
|
845
1474
|
}
|
|
1475
|
+
for (const expected of [
|
|
1476
|
+
'function stateCardCollapsed(section)',
|
|
1477
|
+
"localStorage.getItem('hcc.stateCard.' + section + '.collapsed')",
|
|
1478
|
+
'function stateCardHtml(section, title, count, bodyHtml)',
|
|
1479
|
+
'state-card-toggle',
|
|
1480
|
+
'aria-expanded=',
|
|
1481
|
+
'state-card-collapsed',
|
|
1482
|
+
'function bindStateCardToggles()',
|
|
1483
|
+
"localStorage.setItem('hcc.stateCard.' + section + '.collapsed'",
|
|
1484
|
+
"stateCardHtml('automation'",
|
|
1485
|
+
"stateCardHtml('timeline'",
|
|
1486
|
+
"stateCardHtml('messages'",
|
|
1487
|
+
"stateCardHtml('peers'",
|
|
1488
|
+
"stateCardHtml('tasks'",
|
|
1489
|
+
"stateCardHtml('locks'",
|
|
1490
|
+
'bindStateCardToggles();'
|
|
1491
|
+
]) {
|
|
1492
|
+
if (!html.includes(expected)) fail(`web state panel missing collapsible card support: ${expected}`);
|
|
1493
|
+
}
|
|
1494
|
+
for (const expected of [
|
|
1495
|
+
'id="actionResult"',
|
|
1496
|
+
'data-action="state"',
|
|
1497
|
+
'data-action="status"',
|
|
1498
|
+
'data-action="inbox"',
|
|
1499
|
+
'data-action="task-next"',
|
|
1500
|
+
'data-action="heartbeat"',
|
|
1501
|
+
'data-action="register"',
|
|
1502
|
+
'data-terminal-action="status"',
|
|
1503
|
+
'function runPeerAction(action)',
|
|
1504
|
+
"'/api/peers/' + encodeURIComponent(info.peerId) + '/actions/' + encodeURIComponent(action)",
|
|
1505
|
+
'showActionResult(result)'
|
|
1506
|
+
]) {
|
|
1507
|
+
if (!html.includes(expected)) fail(`web actions missing API result UI: ${expected}`);
|
|
1508
|
+
}
|
|
1509
|
+
if (html.includes('data-send=') || html.includes("document.querySelectorAll('[data-send]')")) {
|
|
1510
|
+
fail('web actions still use implicit terminal command injection');
|
|
1511
|
+
}
|
|
1512
|
+
for (const expected of [
|
|
1513
|
+
'term.onData((data) => {',
|
|
1514
|
+
"ws.send(JSON.stringify({ type: 'input', data }))"
|
|
1515
|
+
]) {
|
|
1516
|
+
if (!html.includes(expected)) fail(`web terminal input forwarding missing: ${expected}`);
|
|
1517
|
+
}
|
|
1518
|
+
if (!html.includes("stateCardHtml('timeline'") || !html.includes('renderTimelineItem') || !html.includes('bodyPinned') || !html.includes('refreshCurrentState')) {
|
|
1519
|
+
fail('web state panel missing collaboration timeline or refresh routing');
|
|
1520
|
+
}
|
|
1521
|
+
if (!html.includes("stateCardHtml('messages'") || !html.includes('messagesData.length')) {
|
|
1522
|
+
fail('web state panel missing dedicated messages card');
|
|
1523
|
+
}
|
|
846
1524
|
|
|
847
1525
|
if (!tmuxAvailable()) return;
|
|
848
1526
|
const started = hccFrom(['peer', 'start', 'other-shell', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc'], otherRoot);
|
|
@@ -852,8 +1530,8 @@ async function multiProjectWebWorkflow() {
|
|
|
852
1530
|
if (rootList.includes('other-shell')) fail(`root project saw second project session:\n${rootList}`);
|
|
853
1531
|
if (!otherList.includes('other-shell')) fail(`second project did not see its session:\n${otherList}`);
|
|
854
1532
|
|
|
855
|
-
const rootSessions = await (await
|
|
856
|
-
const otherSessions = await (await
|
|
1533
|
+
const rootSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
1534
|
+
const otherSessions = await (await runtimeFetch('/api/sessions', {}, { root: otherRoot })).json();
|
|
857
1535
|
if ((rootSessions.sessions || []).some((s) => s.id === 'other-shell')) {
|
|
858
1536
|
fail(`root API saw second project session:\n${JSON.stringify(rootSessions)}`);
|
|
859
1537
|
}
|
|
@@ -861,12 +1539,146 @@ async function multiProjectWebWorkflow() {
|
|
|
861
1539
|
fail(`second project API did not see its session:\n${JSON.stringify(otherSessions)}`);
|
|
862
1540
|
}
|
|
863
1541
|
|
|
1542
|
+
hcc(['register', '--peer', 'web-action-peer', '--kind', 'codex', '--role', 'peer']);
|
|
1543
|
+
hcc(['msg', 'send', '--from', 'human', '--to', 'web-action-peer', '--body', 'web action inbox ok']);
|
|
1544
|
+
const actionStatus = await (await runtimeFetch('/api/peers/web-action-peer/actions/status', {}, { root })).json();
|
|
1545
|
+
if (!actionStatus.ok || actionStatus.action !== 'status' || actionStatus.peer !== 'web-action-peer' || !actionStatus.data || typeof actionStatus.data.unread !== 'number') {
|
|
1546
|
+
fail(`web status action did not return structured status:\n${JSON.stringify(actionStatus, null, 2)}`);
|
|
1547
|
+
}
|
|
1548
|
+
const actionInbox = await (await runtimeFetch('/api/peers/web-action-peer/actions/inbox', {}, { root })).json();
|
|
1549
|
+
if (!actionInbox.ok || actionInbox.action !== 'inbox' || !(actionInbox.data?.messages || []).some((m) => m.body === 'web action inbox ok')) {
|
|
1550
|
+
fail(`web inbox action did not return unread messages:\n${JSON.stringify(actionInbox, null, 2)}`);
|
|
1551
|
+
}
|
|
1552
|
+
const actionState = await (await runtimeFetch('/api/peers/web-action-peer/actions/state', {}, { root })).json();
|
|
1553
|
+
if (!actionState.ok || actionState.action !== 'state' || actionState.data?.automation?.peer?.id !== 'web-action-peer') {
|
|
1554
|
+
fail(`web state action did not return peer automation:\n${JSON.stringify(actionState, null, 2)}`);
|
|
1555
|
+
}
|
|
1556
|
+
const taskOutput = hcc(['task', 'create', '--title', 'web action task', '--body', 'claim through web action']);
|
|
1557
|
+
const taskMatch = taskOutput.match(/created task #(\d+):/);
|
|
1558
|
+
if (!taskMatch) fail(`cannot parse web action task id:\n${taskOutput}`);
|
|
1559
|
+
const actionNext = await (await runtimeFetch('/api/peers/web-action-peer/actions/task-next', {
|
|
1560
|
+
method: 'POST',
|
|
1561
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1562
|
+
body: '{}'
|
|
1563
|
+
}, { root })).json();
|
|
1564
|
+
if (!actionNext.ok || actionNext.action !== 'task-next' || String(actionNext.data?.task?.id) !== taskMatch[1]) {
|
|
1565
|
+
fail(`web task-next action did not claim pending task #${taskMatch[1]}:\n${JSON.stringify(actionNext, null, 2)}`);
|
|
1566
|
+
}
|
|
1567
|
+
const actionHeartbeat = await (await runtimeFetch('/api/peers/web-action-peer/actions/heartbeat', {
|
|
1568
|
+
method: 'POST',
|
|
1569
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1570
|
+
body: JSON.stringify({ renew_locks: true })
|
|
1571
|
+
}, { root })).json();
|
|
1572
|
+
if (!actionHeartbeat.ok || actionHeartbeat.action !== 'heartbeat' || actionHeartbeat.data?.peer !== 'web-action-peer') {
|
|
1573
|
+
fail(`web heartbeat action did not return structured result:\n${JSON.stringify(actionHeartbeat, null, 2)}`);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
const startProvider = async (payload) => {
|
|
1577
|
+
const response = await runtimeFetch('/api/sessions', {
|
|
1578
|
+
method: 'POST',
|
|
1579
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1580
|
+
body: JSON.stringify({
|
|
1581
|
+
env: {
|
|
1582
|
+
HOME: home,
|
|
1583
|
+
PATH: env.PATH,
|
|
1584
|
+
SHELL: process.env.SHELL || 'bash',
|
|
1585
|
+
HCC_FAKE_STAY_ALIVE: '1'
|
|
1586
|
+
},
|
|
1587
|
+
...payload
|
|
1588
|
+
})
|
|
1589
|
+
}, { root });
|
|
1590
|
+
const json = await response.json();
|
|
1591
|
+
if (!response.ok) fail(`web provider session start failed: ${JSON.stringify(json)}`);
|
|
1592
|
+
return json.session;
|
|
1593
|
+
};
|
|
1594
|
+
const stopSession = async (id) => {
|
|
1595
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(id)}/stop`, { method: 'POST' }, { root });
|
|
1596
|
+
};
|
|
1597
|
+
|
|
1598
|
+
const claudeResumeName = `web-claude-resume-${testId}`;
|
|
1599
|
+
const claudeResume = await startProvider({ kind: 'claude', mode: 'resume', resume: claudeResumeName });
|
|
1600
|
+
const expectedClaudePeer = `claude-${claudeResumeName.slice(0, 8)}`;
|
|
1601
|
+
if (!claudeResume.command.includes(`claude --resume ${claudeResumeName}`)) {
|
|
1602
|
+
fail(`web claude resume command wrong:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1603
|
+
}
|
|
1604
|
+
if (claudeResume.id !== expectedClaudePeer || claudeResume.peer_id !== expectedClaudePeer) {
|
|
1605
|
+
fail(`web claude resume did not use canonical provider peer id ${expectedClaudePeer}:\n${JSON.stringify(claudeResume, null, 2)}`);
|
|
1606
|
+
}
|
|
1607
|
+
const claudeRows = providerBindingRows('claude', claudeResumeName);
|
|
1608
|
+
if (claudeRows.length !== 1 ||
|
|
1609
|
+
claudeRows[0].peer !== claudeResume.peer_id ||
|
|
1610
|
+
claudeRows[0].resume_mode !== 'resume' ||
|
|
1611
|
+
claudeRows[0].resume_arg !== claudeResumeName ||
|
|
1612
|
+
claudeRows[0].transport !== 'tmux' ||
|
|
1613
|
+
!claudeRows[0].runtime_target) {
|
|
1614
|
+
fail(`web claude resume binding wrong:\n${JSON.stringify(claudeRows, null, 2)}`);
|
|
1615
|
+
}
|
|
1616
|
+
await stopSession(claudeResume.id);
|
|
1617
|
+
|
|
1618
|
+
const codexResumeName = `web-codex-resume-${testId}`;
|
|
1619
|
+
const codexResume = await startProvider({ kind: 'codex', mode: 'resume', resume: codexResumeName });
|
|
1620
|
+
const expectedCodexPeer = `codex-${codexResumeName.slice(0, 8)}`;
|
|
1621
|
+
if (!codexResume.command.includes(`codex resume ${codexResumeName}`)) {
|
|
1622
|
+
fail(`web codex resume command wrong:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
1623
|
+
}
|
|
1624
|
+
if (codexResume.id !== expectedCodexPeer || codexResume.peer_id !== expectedCodexPeer) {
|
|
1625
|
+
fail(`web codex resume did not use canonical provider peer id ${expectedCodexPeer}:\n${JSON.stringify(codexResume, null, 2)}`);
|
|
1626
|
+
}
|
|
1627
|
+
const codexRows = providerBindingRows('codex', codexResumeName);
|
|
1628
|
+
if (codexRows.length !== 1 ||
|
|
1629
|
+
codexRows[0].peer !== codexResume.peer_id ||
|
|
1630
|
+
codexRows[0].resume_mode !== 'resume' ||
|
|
1631
|
+
codexRows[0].resume_arg !== codexResumeName ||
|
|
1632
|
+
codexRows[0].transport !== 'tmux' ||
|
|
1633
|
+
!codexRows[0].runtime_target) {
|
|
1634
|
+
fail(`web codex resume binding wrong:\n${JSON.stringify(codexRows, null, 2)}`);
|
|
1635
|
+
}
|
|
1636
|
+
await stopSession(codexResume.id);
|
|
1637
|
+
const codexRowsAfterStop = providerBindingRows('codex', codexResumeName);
|
|
1638
|
+
if (codexRowsAfterStop.length !== 1 || codexRowsAfterStop[0].peer !== expectedCodexPeer) {
|
|
1639
|
+
fail(`web codex resume binding was not stable after stop:\n${JSON.stringify(codexRowsAfterStop, null, 2)}`);
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
const resumableResponse = await runtimeFetch('/api/resumable', {}, { root });
|
|
1643
|
+
const resumableJson = await resumableResponse.json();
|
|
1644
|
+
if (!resumableResponse.ok) fail(`resumable API failed: ${JSON.stringify(resumableJson)}`);
|
|
1645
|
+
const resumableRows = resumableJson.resumable || [];
|
|
1646
|
+
const claudeResumable = resumableRows.find((row) => row.provider === 'claude' && row.resume === claudeResumeName);
|
|
1647
|
+
const codexResumable = resumableRows.find((row) => row.provider === 'codex' && row.resume === codexResumeName);
|
|
1648
|
+
if (!claudeResumable || claudeResumable.session_name !== claudeResumeName || claudeResumable.session_id !== null) {
|
|
1649
|
+
fail(`resumable API omitted named claude resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
|
|
1650
|
+
}
|
|
1651
|
+
if (!codexResumable || codexResumable.session_name !== codexResumeName || codexResumable.session_id !== null) {
|
|
1652
|
+
fail(`resumable API omitted named codex resume session:\n${JSON.stringify(resumableRows, null, 2)}`);
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
const codexLast = await startProvider({ kind: 'codex', mode: 'last' });
|
|
1656
|
+
if (codexLast.command !== 'codex resume --last') {
|
|
1657
|
+
fail(`web codex last command wrong:\n${JSON.stringify(codexLast, null, 2)}`);
|
|
1658
|
+
}
|
|
1659
|
+
await stopSession(codexLast.id);
|
|
1660
|
+
|
|
1661
|
+
const claudeContinue = await startProvider({ kind: 'claude', mode: 'continue' });
|
|
1662
|
+
if (claudeContinue.command !== 'claude --continue') {
|
|
1663
|
+
fail(`web claude continue command wrong:\n${JSON.stringify(claudeContinue, null, 2)}`);
|
|
1664
|
+
}
|
|
1665
|
+
await stopSession(claudeContinue.id);
|
|
1666
|
+
|
|
1667
|
+
const badShellResume = await runtimeFetch('/api/sessions', {
|
|
1668
|
+
method: 'POST',
|
|
1669
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1670
|
+
body: JSON.stringify({ kind: 'shell', mode: 'resume', resume: 'not-supported' })
|
|
1671
|
+
}, { root });
|
|
1672
|
+
if (badShellResume.ok) {
|
|
1673
|
+
fail('web shell resume was accepted');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
864
1676
|
const startAuto = async () => {
|
|
865
|
-
const response = await
|
|
1677
|
+
const response = await runtimeFetch('/api/sessions', {
|
|
866
1678
|
method: 'POST',
|
|
867
1679
|
headers: { 'Content-Type': 'application/json' },
|
|
868
1680
|
body: JSON.stringify({ kind: 'shell', command: 'bash --noprofile --norc' })
|
|
869
|
-
});
|
|
1681
|
+
}, { root: otherRoot });
|
|
870
1682
|
const json = await response.json();
|
|
871
1683
|
if (!response.ok) fail(`auto web session start failed: ${JSON.stringify(json)}`);
|
|
872
1684
|
return json.session;
|
|
@@ -876,8 +1688,8 @@ async function multiProjectWebWorkflow() {
|
|
|
876
1688
|
if (!autoOne.id.startsWith('shell-') || !autoTwo.id.startsWith('shell-') || autoOne.id === autoTwo.id) {
|
|
877
1689
|
fail(`auto web session ids were not unique: ${autoOne.id}, ${autoTwo.id}`);
|
|
878
1690
|
}
|
|
879
|
-
await
|
|
880
|
-
await
|
|
1691
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(autoOne.id)}/stop`, { method: 'POST' }, { root: otherRoot });
|
|
1692
|
+
await runtimeFetch(`/api/sessions/${encodeURIComponent(autoTwo.id)}/stop`, { method: 'POST' }, { root: otherRoot });
|
|
881
1693
|
hccFromMaybe(['peer', 'stop', 'other-shell'], otherRoot);
|
|
882
1694
|
}
|
|
883
1695
|
|
|
@@ -897,6 +1709,7 @@ async function tmuxBackedStartWorkflow() {
|
|
|
897
1709
|
await waitForFile(file, 'PTY_OK', 'pty injection');
|
|
898
1710
|
await expectWebSocketMarker('shell-a', 'WS_PTY_OK');
|
|
899
1711
|
await expectResizeReplaceSnapshot('shell-a', 'WS_RESIZE_OK');
|
|
1712
|
+
await expectWebSocketInputVisible('shell-a', 'WS_INPUT_VISIBLE_OK');
|
|
900
1713
|
await expectBoundedTmuxStream('tmux-backed FIFO stream');
|
|
901
1714
|
|
|
902
1715
|
await stopRuntime();
|
|
@@ -908,6 +1721,23 @@ async function tmuxBackedStartWorkflow() {
|
|
|
908
1721
|
hcc(['inject', 'shell-a', `echo PTY_RESTORED_OK > ${restoredFile}`]);
|
|
909
1722
|
await waitForFile(restoredFile, 'PTY_RESTORED_OK', 'tmux restore injection');
|
|
910
1723
|
|
|
1724
|
+
const aliasPeer = 'shell-canonical-alias';
|
|
1725
|
+
parsePane(hcc(['peer', 'start', 'shell-runtime-alias', '--kind', 'shell', '--', 'bash', '--noprofile', '--norc']));
|
|
1726
|
+
moveRuntimeBindingPeer('shell-runtime-alias', aliasPeer);
|
|
1727
|
+
const aliasSessions = await (await runtimeFetch('/api/sessions', {}, { root })).json();
|
|
1728
|
+
const aliasSession = (aliasSessions.sessions || []).find((session) => session.id === 'shell-runtime-alias');
|
|
1729
|
+
if (!aliasSession || aliasSession.peer_id !== aliasPeer) {
|
|
1730
|
+
fail(`sessions API did not expose canonical peer id for runtime alias:\n${JSON.stringify(aliasSessions, null, 2)}`);
|
|
1731
|
+
}
|
|
1732
|
+
const aliasDetected = await (await runtimeFetch('/api/detected', {}, { root })).json();
|
|
1733
|
+
if ((aliasDetected.detected || []).some((peer) => peer.id === aliasPeer || peer.id === 'shell-runtime-alias')) {
|
|
1734
|
+
fail(`detected API showed managed runtime/canonical duplicate:\n${JSON.stringify(aliasDetected, null, 2)}`);
|
|
1735
|
+
}
|
|
1736
|
+
const aliasFile = path.join(outDir, 'runtime-alias-ok');
|
|
1737
|
+
hcc(['inject', aliasPeer, `echo RUNTIME_ALIAS_OK > ${aliasFile}`]);
|
|
1738
|
+
await waitForFile(aliasFile, 'RUNTIME_ALIAS_OK', 'canonical peer injection to runtime alias');
|
|
1739
|
+
hcc(['peer', 'stop', aliasPeer]);
|
|
1740
|
+
|
|
911
1741
|
const canonicalSession = 'canonical-session';
|
|
912
1742
|
insertStaleProviderBinding('claude-stale-canonical', 'claude', canonicalSession);
|
|
913
1743
|
const canonicalStarted = hcc(['peer', 'start', 'claude-live-canonical', '--kind', 'claude', '--resume', canonicalSession], {
|
|
@@ -1126,15 +1956,54 @@ async function downGcPackWorkflow() {
|
|
|
1126
1956
|
hcc(['gc', '--older-than', '0', '--yes']);
|
|
1127
1957
|
const pack = JSON.parse(run('npm', ['pack', '--dry-run', '--json']));
|
|
1128
1958
|
const files = new Set(pack[0]?.files?.map((entry) => entry.path) || []);
|
|
1129
|
-
for (const file of [
|
|
1959
|
+
for (const file of [
|
|
1960
|
+
'assets/logo.svg',
|
|
1961
|
+
'CHANGELOG.md',
|
|
1962
|
+
'docs/commands.md',
|
|
1963
|
+
'docs/commands.zh-CN.md',
|
|
1964
|
+
'lib/guidance.mjs',
|
|
1965
|
+
'lib/package-meta.mjs',
|
|
1966
|
+
'lib/release-notes.mjs',
|
|
1967
|
+
'scripts/github-release.mjs'
|
|
1968
|
+
]) {
|
|
1130
1969
|
if (!files.has(file)) fail(`npm package missing ${file}`);
|
|
1131
1970
|
}
|
|
1971
|
+
const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
|
|
1132
1972
|
const releaseCheck = run('npm', ['run', 'release:check']);
|
|
1133
|
-
if (!releaseCheck.includes(
|
|
1973
|
+
if (!releaseCheck.includes(`release notes ok: ${packageVersion}`)) fail(`release check output wrong:\n${releaseCheck}`);
|
|
1134
1974
|
const releaseNotes = run('npm', ['run', 'release:notes']);
|
|
1135
|
-
if (!releaseNotes.includes(
|
|
1975
|
+
if (!releaseNotes.includes(`## ${packageVersion}`) || !releaseNotes.includes('### Summary')) {
|
|
1136
1976
|
fail(`release notes output wrong:\n${releaseNotes}`);
|
|
1137
1977
|
}
|
|
1978
|
+
const releaseNotesWithV = run(process.execPath, [path.join(repoRoot, 'scripts', 'release-notes.mjs'), '--version', `v${packageVersion}`]);
|
|
1979
|
+
if (!releaseNotesWithV.includes(`## ${packageVersion}`) || releaseNotesWithV.includes(`## v${packageVersion}`)) {
|
|
1980
|
+
fail(`release notes v-prefixed version output wrong:\n${releaseNotesWithV}`);
|
|
1981
|
+
}
|
|
1982
|
+
const githubRelease = JSON.parse(run(process.execPath, [path.join(repoRoot, 'scripts', 'github-release.mjs'), '--dry-run', '--version', packageVersion]));
|
|
1983
|
+
if (!githubRelease.ok || !githubRelease.dry_run || githubRelease.repo !== 'Dullne/hello-cc' || githubRelease.tag !== `v${packageVersion}` || githubRelease.body_length < 100) {
|
|
1984
|
+
fail(`github release dry run output wrong:\n${JSON.stringify(githubRelease, null, 2)}`);
|
|
1985
|
+
}
|
|
1986
|
+
const docsIndex = fs.readFileSync(path.join(repoRoot, 'docs', 'README.md'), 'utf8');
|
|
1987
|
+
const docsIndexZh = fs.readFileSync(path.join(repoRoot, 'docs', 'README.zh-CN.md'), 'utf8');
|
|
1988
|
+
if (!docsIndex.includes('github-release.yml') || !docsIndex.includes('workflow_dispatch') || !docsIndexZh.includes('github-release.yml') || !docsIndexZh.includes('workflow_dispatch')) {
|
|
1989
|
+
fail('docs index missing GitHub Release publishing command');
|
|
1990
|
+
}
|
|
1991
|
+
const workflow = fs.readFileSync(path.join(repoRoot, '.github', 'workflows', 'github-release.yml'), 'utf8');
|
|
1992
|
+
for (const expected of [
|
|
1993
|
+
'Publish GitHub Release',
|
|
1994
|
+
"tags:\n - 'v*'",
|
|
1995
|
+
'workflow_dispatch:',
|
|
1996
|
+
'contents: write',
|
|
1997
|
+
'actions/checkout@v4',
|
|
1998
|
+
'actions/setup-node@v4',
|
|
1999
|
+
'node-version: 24',
|
|
2000
|
+
'npm run release:check',
|
|
2001
|
+
'npm run release:github:dry-run',
|
|
2002
|
+
'npm run release:github',
|
|
2003
|
+
'GITHUB_TOKEN: ${{ github.token }}'
|
|
2004
|
+
]) {
|
|
2005
|
+
if (!workflow.includes(expected)) fail(`github release workflow missing ${expected}`);
|
|
2006
|
+
}
|
|
1138
2007
|
}
|
|
1139
2008
|
|
|
1140
2009
|
function oldNameScan() {
|
|
@@ -1166,6 +2035,41 @@ function syntaxAndHelp() {
|
|
|
1166
2035
|
run(process.execPath, ['--check', path.join(repoRoot, 'bin', 'hcc.mjs')]);
|
|
1167
2036
|
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'setup.mjs')]);
|
|
1168
2037
|
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'discover.mjs')]);
|
|
2038
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'guidance.mjs')]);
|
|
2039
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'package-meta.mjs')]);
|
|
2040
|
+
run(process.execPath, ['--check', path.join(repoRoot, 'lib', 'release-notes.mjs')]);
|
|
2041
|
+
const hccSource = fs.readFileSync(hccBin, 'utf8');
|
|
2042
|
+
for (const expected of [
|
|
2043
|
+
'function scheduleTmuxInputRefresh(session)',
|
|
2044
|
+
"runTmux(['pipe-pane', '-t', session.pane]);",
|
|
2045
|
+
'if (session.inputRefreshTimer) return;',
|
|
2046
|
+
'session.inputRefreshTimer = setTimeout',
|
|
2047
|
+
'scheduleTmuxInputRefresh(session)',
|
|
2048
|
+
"if (session.inputRefreshTimer) { clearTimeout(session.inputRefreshTimer); session.inputRefreshTimer = null; }",
|
|
2049
|
+
"broadcast(session, { type: 'replace', data: refreshTmuxSnapshot(session) });"
|
|
2050
|
+
]) {
|
|
2051
|
+
if (!hccSource.includes(expected)) fail(`web terminal input refresh support missing: ${expected}`);
|
|
2052
|
+
}
|
|
2053
|
+
for (const expected of [
|
|
2054
|
+
'function webPeerAction(projectCtx, peer, action, input = {})',
|
|
2055
|
+
'function claimNextTaskForPeer(db, peer, force = false)',
|
|
2056
|
+
'function statusSummary(ctx, peer = null, identity = null)',
|
|
2057
|
+
'const peerActionMatch = url.pathname.match(/^\\/api\\/peers\\/([^/]+)\\/actions\\/([^/]+)$/)',
|
|
2058
|
+
"const readOnly = ['status', 'state', 'inbox'].includes(action)",
|
|
2059
|
+
"sendJson(res, 200, webPeerAction(reqCtx, peer, action, input));"
|
|
2060
|
+
]) {
|
|
2061
|
+
if (!hccSource.includes(expected)) fail(`web peer action API support missing: ${expected}`);
|
|
2062
|
+
}
|
|
2063
|
+
const packageVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).version;
|
|
2064
|
+
const cliVersion = run(process.execPath, [hccBin, '--version']).trim();
|
|
2065
|
+
if (cliVersion !== packageVersion) {
|
|
2066
|
+
fail(`CLI version ${cliVersion} does not match package.json ${packageVersion}`);
|
|
2067
|
+
}
|
|
2068
|
+
if (!hccSource.includes("import { readPackageMeta } from '../lib/package-meta.mjs'") ||
|
|
2069
|
+
!hccSource.includes('const VERSION = PACKAGE_META.version') ||
|
|
2070
|
+
!hccSource.includes('writeGuidanceForRoot(ctx.root)')) {
|
|
2071
|
+
fail('CLI still has duplicated package metadata or guidance wiring');
|
|
2072
|
+
}
|
|
1169
2073
|
const mainHelp = run(process.execPath, [hccBin, '--help']);
|
|
1170
2074
|
if (mainHelp.includes('setup') || mainHelp.includes('--web-managed')) {
|
|
1171
2075
|
fail(`public help exposes maintenance or removed commands:\n${mainHelp}`);
|
|
@@ -1173,9 +2077,33 @@ function syntaxAndHelp() {
|
|
|
1173
2077
|
if (!mainHelp.includes(' update Update the global npm install of hello-cc')) {
|
|
1174
2078
|
fail(`main help missing update command:\n${mainHelp}`);
|
|
1175
2079
|
}
|
|
2080
|
+
if (!mainHelp.includes(' state [--peer ID] Show timeline and next coordination action')) {
|
|
2081
|
+
fail(`main help missing state command:\n${mainHelp}`);
|
|
2082
|
+
}
|
|
2083
|
+
if (!mainHelp.includes(' team <subcommand> Plan, start, and inspect explicit task teams')) {
|
|
2084
|
+
fail(`main help missing team command:\n${mainHelp}`);
|
|
2085
|
+
}
|
|
1176
2086
|
if (!mainHelp.includes(' uninstall Remove hooks, shims, and optional project data')) {
|
|
1177
2087
|
fail(`main help missing uninstall command:\n${mainHelp}`);
|
|
1178
2088
|
}
|
|
2089
|
+
const msgHelp = run(process.execPath, [hccBin, 'msg', '--help']);
|
|
2090
|
+
if (!msgHelp.includes('msg reply') || !msgHelp.includes('msg thread')) {
|
|
2091
|
+
fail(`msg help missing reply/thread commands:\n${msgHelp}`);
|
|
2092
|
+
}
|
|
2093
|
+
const taskHelp = run(process.execPath, [hccBin, 'task', '--help']);
|
|
2094
|
+
if (!taskHelp.includes('task next [--peer ID] [--force]') ||
|
|
2095
|
+
!taskHelp.includes('existing claimed/running/review/blocked task') ||
|
|
2096
|
+
!taskHelp.includes('task create --title TEXT --parent N')) {
|
|
2097
|
+
fail(`task help missing current-task task next semantics:\n${taskHelp}`);
|
|
2098
|
+
}
|
|
2099
|
+
const teamHelp = run(process.execPath, [hccBin, 'team', '--help']);
|
|
2100
|
+
if (!teamHelp.includes('hcc team') || !teamHelp.includes('team plan') || !teamHelp.includes('team start') || !teamHelp.includes('team status')) {
|
|
2101
|
+
fail(`team help missing expected content:\n${teamHelp}`);
|
|
2102
|
+
}
|
|
2103
|
+
const stateHelp = run(process.execPath, [hccBin, 'state', '--help']);
|
|
2104
|
+
if (!stateHelp.includes('hcc state') || !stateHelp.includes('automation.next_action.argv') || !stateHelp.includes('automation.current_task') || !stateHelp.includes('hcc team plan')) {
|
|
2105
|
+
fail(`state help missing expected content:\n${stateHelp}`);
|
|
2106
|
+
}
|
|
1179
2107
|
const updateHelp = run(process.execPath, [hccBin, 'update', '--help']);
|
|
1180
2108
|
if (!updateHelp.includes('hcc update') || !updateHelp.includes('npm install -g @logicseek/hello-cc@TAG')) {
|
|
1181
2109
|
fail(`update help missing expected content:\n${updateHelp}`);
|
|
@@ -1200,7 +2128,7 @@ function syntaxAndHelp() {
|
|
|
1200
2128
|
if (runHelp.includes('--web-managed')) fail(`run help exposes removed --web-managed:\n${runHelp}`);
|
|
1201
2129
|
const subcommandHelpCases = [
|
|
1202
2130
|
['task', 'done', 'hcc task done'],
|
|
1203
|
-
['msg', '
|
|
2131
|
+
['msg', 'reply', 'hcc msg reply'],
|
|
1204
2132
|
['peer', 'attach', 'peer attach'],
|
|
1205
2133
|
['lock', 'release', 'hcc lock release'],
|
|
1206
2134
|
['handoff', 'create', 'hcc handoff create'],
|
|
@@ -1233,8 +2161,12 @@ function uninstallWorkflow() {
|
|
|
1233
2161
|
|
|
1234
2162
|
run(process.execPath, [hccBin, '--root', uninstallRoot, 'setup', '--quiet'], { env: uninstallEnv });
|
|
1235
2163
|
ensureFile(path.join(uninstallRoot, '.hello-cc', 'mesh.db'));
|
|
2164
|
+
ensureFile(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
|
|
1236
2165
|
ensureFile(path.join(uninstallRoot, 'CLAUDE.md'));
|
|
1237
2166
|
ensureFile(path.join(uninstallRoot, 'AGENTS.md'));
|
|
2167
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, '.hello-cc', 'HCC.md'));
|
|
2168
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, 'CLAUDE.md'));
|
|
2169
|
+
assertGuidanceLockPolicy(path.join(uninstallRoot, 'AGENTS.md'));
|
|
1238
2170
|
ensureFile(path.join(uninstallHome, '.claude', 'settings.json'));
|
|
1239
2171
|
ensureFile(path.join(uninstallHome, '.codex', 'hooks.json'));
|
|
1240
2172
|
ensureFile(path.join(uninstallHome, '.hcc-shims', 'claude'));
|