agent-yes 1.290.7 → 1.291.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/SUPPORTED_CLIS-BZP767UR.js +12 -0
- package/dist/{SUPPORTED_CLIS-hTRegnm5.js → SUPPORTED_CLIS-TiOdGi1h.js} +2 -2
- package/dist/{agentShare-C_LTEKEb.js → agentShare-D4N8StHO.js} +8 -8
- package/dist/{askCli-DR9vhdcV.js → askCli-DFsWcgpF.js} +3 -3
- package/dist/{browser-oqC3Ln2a.js → browser-wkzxw6lx.js} +3 -2
- package/dist/{callback-DAQ8wCtJ.js → callback-Bc2gnr_f.js} +3 -3
- package/dist/{callback-BEvLUUBR.js → callback-yCpK6Qpg.js} +7 -7
- package/dist/{channels-hOG3-R_3.js → channels-aYwLMbFV.js} +2 -2
- package/dist/cli.js +9 -9
- package/dist/{cmdPs-BAaLB4Pz.js → cmdPs-D5L3Eyh5.js} +7 -7
- package/dist/{cwdConflictWarn-BgXN56Jc.js → cwdConflictWarn-sQUONAye.js} +2 -2
- package/dist/{forkNested-CJ7lQQCe.js → forkNested-xtEeYpBQ.js} +3 -3
- package/dist/{globalPidIndex-DoaKEUkO.js → globalPidIndex-CRH9L9tn.js} +139 -12
- package/dist/globalPidIndex-DC93JhHQ.js +4 -0
- package/dist/index.js +5 -5
- package/dist/{messageLog-CVRSTLza.js → messageLog-Brx3w6zT.js} +2 -1
- package/dist/{notifyDaemon-Dnbe66Fl.js → notifyDaemon-CvUtRnAB.js} +7 -7
- package/dist/{parentLink-fdiuB13i.js → parentLink-BILJ32nA.js} +2 -2
- package/dist/{parentLink-bpDGuQi6.js → parentLink-CfbxykCv.js} +2 -2
- package/dist/{parentPingLoop-DWv-xJoi.js → parentPingLoop-CVOCiQp2.js} +4 -4
- package/dist/pidStore-Bpe8i_oU.js +6 -0
- package/dist/{pidStore-B73xnAeT.js → pidStore-DXWqzTSz.js} +2 -2
- package/dist/{remotes-DTXPW8U1.js → remotes-tmKwrr20.js} +3 -3
- package/dist/{remotes-C696AJzL.js → remotes-wPL0JtBs.js} +2 -2
- package/dist/{rustBinary-CZRPfa4y.js → rustBinary-C_EbyMTk.js} +2 -2
- package/dist/{rustBinary-jO6EN8LR.js → rustBinary-Clbk_K5c.js} +2 -2
- package/dist/{schedule-Hao0WqyJ.js → schedule-Ba_4wNVK.js} +7 -7
- package/dist/{serve-B2nisui8.js → serve-CJDQq_Jp.js} +46 -40
- package/dist/{setup-vCDTqyqR.js → setup-DwLAkbTC.js} +2 -2
- package/dist/share-Bmnd_qT0.js +4 -0
- package/dist/{share-DTlaFn6s.js → share-C9oGvjXT.js} +13 -4
- package/dist/spawnGate-BUEswFgo.js +5 -0
- package/dist/{spawnGate-B26UnzqP.js → spawnGate-YpHopveO.js} +2 -2
- package/dist/{subcommands-BpFdlCY0.js → subcommands-x8Pe7DrF.js} +6 -6
- package/dist/{subcommands-D6LBtVPM.js → subcommands-yWgVRM2r.js} +273 -45
- package/dist/{terminal-iGHEnc7Z.js → terminal-Dv73BTE-.js} +2 -2
- package/dist/terminal.js +1 -1
- package/dist/{todoCli-azqbjBtz.js → todoCli-DSQXAcwA.js} +3 -3
- package/dist/{todoIdentity-NEceTmEe.js → todoIdentity-CAmnkzzP.js} +2 -2
- package/dist/{trayApp-BdNlQCFx.js → trayApp-BRw8f0mT.js} +2 -2
- package/dist/trayApp-BjQ5V3WV.js +5 -0
- package/dist/{ts-oyuaLWWx.js → ts-C8PxhJZM.js} +7 -7
- package/dist/{versionChecker-CMjS88mL.js → versionChecker-CWPGASjD.js} +2 -2
- package/dist/webrtcLink-CnQf9pmu.js +73 -0
- package/dist/{webrtcRemote-L5NxhLLR.js → webrtcRemote-yvMYsij3.js} +2 -2
- package/dist/{widget-OvkqW2Z6.js → widget-DhBSNUey.js} +3 -3
- package/dist/widgets.js +1 -1
- package/dist/{ws-BeMouYAU.js → ws-CMX0pkti.js} +2 -2
- package/dist/{ws-qZy7Giub.js → ws-DZ9YAXlY.js} +7 -7
- package/lab/ui/console-logic.js +27 -4
- package/package.json +1 -1
- package/ts/crossTreeClaim.bun.spec.ts +76 -0
- package/ts/crossTreeClaim.spec.ts +76 -0
- package/ts/globalPidIndex.spec.ts +153 -0
- package/ts/globalPidIndex.ts +151 -10
- package/ts/messageLog.bun.spec.ts +33 -0
- package/ts/messageLog.spec.ts +33 -0
- package/ts/messageLog.ts +3 -0
- package/ts/rawInputDeclared.bun.spec.ts +68 -0
- package/ts/rawInputDeclared.spec.ts +68 -0
- package/ts/remoteHealth.bun.spec.ts +130 -0
- package/ts/remoteHealth.spec.ts +130 -0
- package/ts/remoteHealth.ts +149 -0
- package/ts/restartKillGuard.bun.spec.ts +40 -0
- package/ts/restartKillGuard.spec.ts +40 -0
- package/ts/senderAncestry.ts +45 -7
- package/ts/serve.ts +46 -20
- package/ts/share.bun.spec.ts +24 -2
- package/ts/share.spec.ts +24 -2
- package/ts/share.ts +37 -5
- package/ts/subcommands.ts +239 -19
- package/ts/terminal/browser.ts +5 -1
- package/ts/webrtcLink.bun.spec.ts +45 -1
- package/ts/webrtcLink.spec.ts +45 -1
- package/ts/webrtcLink.ts +63 -19
- package/dist/SUPPORTED_CLIS-CuvT3tKq.js +0 -12
- package/dist/globalPidIndex-yvWZXeWT.js +0 -4
- package/dist/pidStore-V51mbgZY.js +0 -6
- package/dist/share-BvU4I8BI.js +0 -4
- package/dist/spawnGate-C8MSRt7X.js +0 -5
- package/dist/trayApp-Br2T2Yrb.js +0 -5
- package/dist/webrtcLink-DLaJfTcB.js +0 -45
package/ts/globalPidIndex.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { Dirent } from "fs";
|
|
2
|
+
import { appendFile, mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "fs/promises";
|
|
2
3
|
import { homedir } from "os";
|
|
3
4
|
import type { AgentPermissions } from "./agentPermissions.ts";
|
|
4
5
|
import path from "path";
|
|
@@ -246,6 +247,33 @@ function logSiblings(logFile: string | null): string[] {
|
|
|
246
247
|
return [`${base}.raw.log`, `${base}.log`, `${base}.lines.log`, `${base}.debug.log`];
|
|
247
248
|
}
|
|
248
249
|
|
|
250
|
+
export interface LogGcResult {
|
|
251
|
+
/** Absolute paths of the log files that were removed. */
|
|
252
|
+
removed: string[];
|
|
253
|
+
/** Total bytes reclaimed (0 for files whose size could not be read). */
|
|
254
|
+
freedBytes: number;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Delete one log file, reporting the bytes it held. Returns null when nothing
|
|
259
|
+
* was removed (already gone, or not ours to delete) so callers can count only
|
|
260
|
+
* real deletions.
|
|
261
|
+
*/
|
|
262
|
+
async function removeLogFile(file: string): Promise<number | null> {
|
|
263
|
+
let size = 0;
|
|
264
|
+
try {
|
|
265
|
+
size = (await stat(file)).size;
|
|
266
|
+
} catch {
|
|
267
|
+
// Unreadable/missing — unlink below still decides whether it counted.
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
await unlink(file);
|
|
271
|
+
return size;
|
|
272
|
+
} catch {
|
|
273
|
+
return null; // missing / already gone — ignore
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
249
277
|
/**
|
|
250
278
|
* Index-driven retention sweep: delete the log files of sessions whose process
|
|
251
279
|
* is gone (exited or dead pid) and that started longer ago than the retention
|
|
@@ -254,30 +282,143 @@ function logSiblings(logFile: string | null): string[] {
|
|
|
254
282
|
* Best-effort; never throws. Returns the number of files removed.
|
|
255
283
|
*/
|
|
256
284
|
export async function pruneOldLogs(maxAgeMs: number = retentionMs()): Promise<number> {
|
|
285
|
+
const { removed } = await pruneOldLogsDetailed(maxAgeMs);
|
|
286
|
+
return removed.length;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function pruneOldLogsDetailed(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
|
|
290
|
+
const result: LogGcResult = { removed: [], freedBytes: 0 };
|
|
257
291
|
let records: GlobalPidRecord[];
|
|
258
292
|
try {
|
|
259
293
|
records = await readGlobalPidsRaw();
|
|
260
294
|
} catch {
|
|
261
|
-
return
|
|
295
|
+
return result;
|
|
262
296
|
}
|
|
263
297
|
const now = Date.now();
|
|
264
|
-
let removed = 0;
|
|
265
298
|
for (const r of records) {
|
|
266
299
|
const dead = r.status === "exited" || !isProcessAlive(r.pid);
|
|
267
300
|
const old = now - (r.started_at ?? now) > maxAgeMs;
|
|
268
301
|
if (!dead || !old) continue;
|
|
269
302
|
for (const f of logSiblings(r.log_file)) {
|
|
303
|
+
const freed = await removeLogFile(f);
|
|
304
|
+
if (freed === null) continue;
|
|
305
|
+
result.removed.push(f);
|
|
306
|
+
result.freedBytes += freed;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (result.removed.length > 0) {
|
|
310
|
+
logger.debug(`[globalPidIndex] pruned ${result.removed.length} stale log file(s)`);
|
|
311
|
+
await maybeCompactGlobalPids();
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** `<pid>.log`, `<pid>.raw.log`, `<pid>.lines.log`, `<pid>.debug.log` — nothing else. */
|
|
317
|
+
const PID_LOG_FILE = /^(\d+)\.(?:raw\.log|lines\.log|debug\.log|log)$/;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Every `.agent-yes/` dir that might hold logs: this process's cwd plus the
|
|
321
|
+
* dir of each path the index knows about. Records are the only breadcrumb to
|
|
322
|
+
* *other* projects, so a pid whose record is gone is still reachable as long
|
|
323
|
+
* as some sibling record points at the same dir.
|
|
324
|
+
*/
|
|
325
|
+
function candidateLogDirs(records: GlobalPidRecord[]): string[] {
|
|
326
|
+
const dirs = new Set<string>([path.resolve(process.cwd(), ".agent-yes")]);
|
|
327
|
+
for (const r of records) {
|
|
328
|
+
if (r.log_file) dirs.add(path.resolve(path.dirname(r.log_file)));
|
|
329
|
+
if (r.cwd) dirs.add(path.resolve(r.cwd, ".agent-yes"));
|
|
330
|
+
}
|
|
331
|
+
return [...dirs];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Directory-driven sweep for **orphaned** logs — the ones `pruneOldLogs` can
|
|
336
|
+
* never see.
|
|
337
|
+
*
|
|
338
|
+
* `maybeCompactGlobalPids` drops records that are dead AND exited, but
|
|
339
|
+
* `pruneOldLogs` only deletes a log once its record is older than the
|
|
340
|
+
* retention window. A session that exits cleanly is therefore compacted out of
|
|
341
|
+
* the index long before its log ages out, and with the record goes the only
|
|
342
|
+
* record of the `log_file` path — the file is then unreclaimable by any
|
|
343
|
+
* index-driven pass. Left alone, those accumulate for as long as a machine
|
|
344
|
+
* keeps running agents, and a single long-lived session's raw log can reach
|
|
345
|
+
* hundreds of MiB on its own.
|
|
346
|
+
*
|
|
347
|
+
* So this pass ignores the index for *what* to delete and trusts the
|
|
348
|
+
* filesystem: in each candidate dir, any `<pid>.*.log` whose pid is no longer
|
|
349
|
+
* running and whose mtime is outside the retention window. Guards:
|
|
350
|
+
*
|
|
351
|
+
* - the name must parse as a pid log (never `inbox.jsonl`, `pid.sqlite`, …);
|
|
352
|
+
* - `isProcessAlive(pid)` must be false — pid reuse can only make a dead pid
|
|
353
|
+
* look alive, which keeps a file that a later run collects, never the
|
|
354
|
+
* reverse;
|
|
355
|
+
* - mtime, not `started_at`, bounds the age, so a log still being appended to
|
|
356
|
+
* is safe even when its session started long ago.
|
|
357
|
+
*
|
|
358
|
+
* Best-effort; never throws.
|
|
359
|
+
*/
|
|
360
|
+
export async function sweepOrphanLogs(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
|
|
361
|
+
const result: LogGcResult = { removed: [], freedBytes: 0 };
|
|
362
|
+
let records: GlobalPidRecord[] = [];
|
|
363
|
+
try {
|
|
364
|
+
records = await readGlobalPidsRaw();
|
|
365
|
+
} catch {
|
|
366
|
+
// No index at all — the cwd is still worth sweeping.
|
|
367
|
+
}
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
|
|
370
|
+
for (const dir of candidateLogDirs(records)) {
|
|
371
|
+
let entries: Dirent[];
|
|
372
|
+
try {
|
|
373
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
374
|
+
} catch {
|
|
375
|
+
continue; // dir gone / unreadable — nothing to collect here
|
|
376
|
+
}
|
|
377
|
+
for (const entry of entries) {
|
|
378
|
+
if (!entry.isFile()) continue;
|
|
379
|
+
const match = PID_LOG_FILE.exec(entry.name);
|
|
380
|
+
if (!match) continue;
|
|
381
|
+
const pid = Number(match[1]);
|
|
382
|
+
if (!Number.isInteger(pid) || pid <= 0 || isProcessAlive(pid)) continue;
|
|
383
|
+
|
|
384
|
+
const file = path.join(dir, entry.name);
|
|
385
|
+
let mtimeMs: number;
|
|
270
386
|
try {
|
|
271
|
-
await
|
|
272
|
-
removed++;
|
|
387
|
+
mtimeMs = (await stat(file)).mtimeMs;
|
|
273
388
|
} catch {
|
|
274
|
-
//
|
|
389
|
+
continue; // raced with deletion
|
|
275
390
|
}
|
|
391
|
+
if (now - mtimeMs <= maxAgeMs) continue; // still inside the window
|
|
392
|
+
|
|
393
|
+
const freed = await removeLogFile(file);
|
|
394
|
+
if (freed === null) continue;
|
|
395
|
+
result.removed.push(file);
|
|
396
|
+
result.freedBytes += freed;
|
|
276
397
|
}
|
|
277
398
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
399
|
+
|
|
400
|
+
if (result.removed.length > 0) {
|
|
401
|
+
logger.debug(`[globalPidIndex] swept ${result.removed.length} orphaned log file(s)`);
|
|
402
|
+
}
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The full log reclaim `ay gc` runs: the index-driven retention pass first
|
|
408
|
+
* (it also compacts the index), then the directory sweep for whatever the
|
|
409
|
+
* index no longer remembers. Paths are deduped, so a file both passes could
|
|
410
|
+
* claim is only counted once.
|
|
411
|
+
*/
|
|
412
|
+
export async function gcLogs(maxAgeMs: number = retentionMs()): Promise<LogGcResult> {
|
|
413
|
+
const pruned = await pruneOldLogsDetailed(maxAgeMs);
|
|
414
|
+
const swept = await sweepOrphanLogs(maxAgeMs);
|
|
415
|
+
const seen = new Set(pruned.removed);
|
|
416
|
+
const result: LogGcResult = { ...pruned };
|
|
417
|
+
for (const f of swept.removed) {
|
|
418
|
+
if (seen.has(f)) continue;
|
|
419
|
+
seen.add(f);
|
|
420
|
+
result.removed.push(f);
|
|
281
421
|
}
|
|
282
|
-
|
|
422
|
+
result.freedBytes = pruned.freedBytes + swept.freedBytes;
|
|
423
|
+
return result;
|
|
283
424
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
shouldRecord,
|
|
15
15
|
type MessageRecord,
|
|
16
16
|
} from "./messageLog.ts";
|
|
17
|
+
import { envelopeAttribution } from "./subcommands.ts";
|
|
17
18
|
|
|
18
19
|
function makeRecord(over: Partial<MessageRecord> = {}): MessageRecord {
|
|
19
20
|
return {
|
|
@@ -540,3 +541,35 @@ describe("messageLog sender provenance — derived vs asserted", () => {
|
|
|
540
541
|
).toBe(true);
|
|
541
542
|
});
|
|
542
543
|
});
|
|
544
|
+
|
|
545
|
+
describe("unverifiable is not contradicted", () => {
|
|
546
|
+
// The direction matters more than the fact. Collapsing UNKNOWN into SUSPICIOUS
|
|
547
|
+
// trains every reader to dismiss the marker on legitimate traffic, so when a
|
|
548
|
+
// real contradiction arrives it reads as more of the same — the alarm is spent
|
|
549
|
+
// before its first true positive. The reverse error would merely miss one.
|
|
550
|
+
it("stays SILENT in the envelope when there is nothing to disagree with", () => {
|
|
551
|
+
expect(envelopeAttribution("env-unverified")).toBe("");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("keeps the loud marker for a genuine contradiction only", () => {
|
|
555
|
+
expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("still records the distinction for a reader who wants it", () => {
|
|
559
|
+
// Silent in the body is not the same as thrown away: `ay msgs` shows it,
|
|
560
|
+
// and from_via carries it for anything that wants to weigh it.
|
|
561
|
+
const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
|
|
562
|
+
expect(senderLabel(makeRecord({ from, from_via: "env-unverified" }))).toBe(
|
|
563
|
+
"claude #1111 (unverified)",
|
|
564
|
+
);
|
|
565
|
+
expect(senderLabel(makeRecord({ from, from_via: "env-uncorroborated" }))).toBe(
|
|
566
|
+
"claude #1111 (UNCORROBORATED)",
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("does not weaken the honest path", () => {
|
|
571
|
+
const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
|
|
572
|
+
expect(senderLabel(makeRecord({ from, from_via: "env" }))).toBe("claude #1111");
|
|
573
|
+
expect(envelopeAttribution("env")).toBe("");
|
|
574
|
+
});
|
|
575
|
+
});
|
package/ts/messageLog.spec.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
shouldRecord,
|
|
15
15
|
type MessageRecord,
|
|
16
16
|
} from "./messageLog.ts";
|
|
17
|
+
import { envelopeAttribution } from "./subcommands.ts";
|
|
17
18
|
|
|
18
19
|
function makeRecord(over: Partial<MessageRecord> = {}): MessageRecord {
|
|
19
20
|
return {
|
|
@@ -540,3 +541,35 @@ describe("messageLog sender provenance — derived vs asserted", () => {
|
|
|
540
541
|
).toBe(true);
|
|
541
542
|
});
|
|
542
543
|
});
|
|
544
|
+
|
|
545
|
+
describe("unverifiable is not contradicted", () => {
|
|
546
|
+
// The direction matters more than the fact. Collapsing UNKNOWN into SUSPICIOUS
|
|
547
|
+
// trains every reader to dismiss the marker on legitimate traffic, so when a
|
|
548
|
+
// real contradiction arrives it reads as more of the same — the alarm is spent
|
|
549
|
+
// before its first true positive. The reverse error would merely miss one.
|
|
550
|
+
it("stays SILENT in the envelope when there is nothing to disagree with", () => {
|
|
551
|
+
expect(envelopeAttribution("env-unverified")).toBe("");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("keeps the loud marker for a genuine contradiction only", () => {
|
|
555
|
+
expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("still records the distinction for a reader who wants it", () => {
|
|
559
|
+
// Silent in the body is not the same as thrown away: `ay msgs` shows it,
|
|
560
|
+
// and from_via carries it for anything that wants to weigh it.
|
|
561
|
+
const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
|
|
562
|
+
expect(senderLabel(makeRecord({ from, from_via: "env-unverified" }))).toBe(
|
|
563
|
+
"claude #1111 (unverified)",
|
|
564
|
+
);
|
|
565
|
+
expect(senderLabel(makeRecord({ from, from_via: "env-uncorroborated" }))).toBe(
|
|
566
|
+
"claude #1111 (UNCORROBORATED)",
|
|
567
|
+
);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("does not weaken the honest path", () => {
|
|
571
|
+
const from = { pid: 1111, cli: "claude", cwd: "/repo/alpha", agent_id: "agent-A" };
|
|
572
|
+
expect(senderLabel(makeRecord({ from, from_via: "env" }))).toBe("claude #1111");
|
|
573
|
+
expect(envelopeAttribution("env")).toBe("");
|
|
574
|
+
});
|
|
575
|
+
});
|
package/ts/messageLog.ts
CHANGED
|
@@ -183,6 +183,9 @@ export function senderLabel(record: MessageRecord): string {
|
|
|
183
183
|
// The one attributed value that is only a claim — say so, since a receiver
|
|
184
184
|
// weighing whether to act on it cannot otherwise tell it from a fact.
|
|
185
185
|
if (record.from_via === "env-uncorroborated") return `${name} (UNCORROBORATED)`;
|
|
186
|
+
// Visible in a listing, where a human is reading deliberately, but never in
|
|
187
|
+
// the envelope — see envelopeAttribution.
|
|
188
|
+
if (record.from_via === "env-unverified") return `${name} (unverified)`;
|
|
186
189
|
return name;
|
|
187
190
|
}
|
|
188
191
|
// No agent identified. Say what WAS measured rather than "unattributed" —
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// #453 made the PRODUCER declare a terminal wire (`raw: true`), because no
|
|
6
|
+
// byte-level predicate on the server may claim a keystroke or a mouse report —
|
|
7
|
+
// those shapes are indistinguishable from a short message. A declaration only
|
|
8
|
+
// works if every producer makes it, and #453 landed it on one of three: the
|
|
9
|
+
// other two kept flooding capacity-capped inboxes, where a mouse scrolled over
|
|
10
|
+
// a pane evicts the real messages behind it.
|
|
11
|
+
//
|
|
12
|
+
// So this is the guard the design needs: any `/api/send` write that declines to
|
|
13
|
+
// submit (`code: "none"`) is raw terminal input and must say so.
|
|
14
|
+
const ROOTS = ["ts", "lab/ui"];
|
|
15
|
+
const SKIP = new Set(["node_modules", "cf", "dist", "public"]);
|
|
16
|
+
const RAW_SEND = /code:\s*"none"/;
|
|
17
|
+
|
|
18
|
+
function sources(dir: string, out: string[] = []): string[] {
|
|
19
|
+
for (const e of readdirSync(dir)) {
|
|
20
|
+
if (SKIP.has(e)) continue;
|
|
21
|
+
const p = join(dir, e);
|
|
22
|
+
if (statSync(p).isDirectory()) sources(p, out);
|
|
23
|
+
else if (/\.(ts|js)$/.test(e) && !/\.spec\./.test(e)) out.push(p);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call sites only — prose in a comment describing the protocol is not one.
|
|
30
|
+
*
|
|
31
|
+
* Line comments are stripped; block comments deliberately are not. Tracking
|
|
32
|
+
* them needs a lexer, and getting it wrong wedges the scan silently. Prose
|
|
33
|
+
* inside a block comment can only ever ADD a false offender, which fails
|
|
34
|
+
* loudly and is fixed by rewording; it can never hide a real producer. The
|
|
35
|
+
* count assertion below covers the opposite slip, a scan that stops matching.
|
|
36
|
+
*
|
|
37
|
+
* Split on /\r?\n/, not "\n": on a CRLF checkout every line would keep a
|
|
38
|
+
* trailing \r, and `.` does not match \r in JS, so `//.*$` silently stops
|
|
39
|
+
* stripping comments and every line of prose about the protocol becomes a
|
|
40
|
+
* false offender. This test failed on Windows only, for exactly that.
|
|
41
|
+
*/
|
|
42
|
+
function callSites(file: string): { line: number; window: string }[] {
|
|
43
|
+
const lines = readFileSync(file, "utf-8").split(/\r?\n/);
|
|
44
|
+
const hits: { line: number; window: string }[] = [];
|
|
45
|
+
lines.forEach((raw, i) => {
|
|
46
|
+
const code = raw.replace(/\/\/.*$/, "");
|
|
47
|
+
if (RAW_SEND.test(code)) hits.push({ line: i + 1, window: code + (lines[i + 1] ?? "") });
|
|
48
|
+
});
|
|
49
|
+
return hits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("every raw-input producer declares itself", () => {
|
|
53
|
+
const files = ROOTS.flatMap((r) => sources(r));
|
|
54
|
+
|
|
55
|
+
it('no /api/send call uses code:"none" without raw:true', () => {
|
|
56
|
+
const offenders = files.flatMap((f) =>
|
|
57
|
+
callSites(f)
|
|
58
|
+
.filter((h) => !/raw:\s*true/.test(h.window))
|
|
59
|
+
.map((h) => `${f}:${h.line}`),
|
|
60
|
+
);
|
|
61
|
+
expect(offenders).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("still finds the producers (a scan that matches nothing passes forever)", () => {
|
|
65
|
+
const total = files.reduce((n, f) => n + callSites(f).length, 0);
|
|
66
|
+
expect(total).toBeGreaterThanOrEqual(3);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// #453 made the PRODUCER declare a terminal wire (`raw: true`), because no
|
|
6
|
+
// byte-level predicate on the server may claim a keystroke or a mouse report —
|
|
7
|
+
// those shapes are indistinguishable from a short message. A declaration only
|
|
8
|
+
// works if every producer makes it, and #453 landed it on one of three: the
|
|
9
|
+
// other two kept flooding capacity-capped inboxes, where a mouse scrolled over
|
|
10
|
+
// a pane evicts the real messages behind it.
|
|
11
|
+
//
|
|
12
|
+
// So this is the guard the design needs: any `/api/send` write that declines to
|
|
13
|
+
// submit (`code: "none"`) is raw terminal input and must say so.
|
|
14
|
+
const ROOTS = ["ts", "lab/ui"];
|
|
15
|
+
const SKIP = new Set(["node_modules", "cf", "dist", "public"]);
|
|
16
|
+
const RAW_SEND = /code:\s*"none"/;
|
|
17
|
+
|
|
18
|
+
function sources(dir: string, out: string[] = []): string[] {
|
|
19
|
+
for (const e of readdirSync(dir)) {
|
|
20
|
+
if (SKIP.has(e)) continue;
|
|
21
|
+
const p = join(dir, e);
|
|
22
|
+
if (statSync(p).isDirectory()) sources(p, out);
|
|
23
|
+
else if (/\.(ts|js)$/.test(e) && !/\.spec\./.test(e)) out.push(p);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Call sites only — prose in a comment describing the protocol is not one.
|
|
30
|
+
*
|
|
31
|
+
* Line comments are stripped; block comments deliberately are not. Tracking
|
|
32
|
+
* them needs a lexer, and getting it wrong wedges the scan silently. Prose
|
|
33
|
+
* inside a block comment can only ever ADD a false offender, which fails
|
|
34
|
+
* loudly and is fixed by rewording; it can never hide a real producer. The
|
|
35
|
+
* count assertion below covers the opposite slip, a scan that stops matching.
|
|
36
|
+
*
|
|
37
|
+
* Split on /\r?\n/, not "\n": on a CRLF checkout every line would keep a
|
|
38
|
+
* trailing \r, and `.` does not match \r in JS, so `//.*$` silently stops
|
|
39
|
+
* stripping comments and every line of prose about the protocol becomes a
|
|
40
|
+
* false offender. This test failed on Windows only, for exactly that.
|
|
41
|
+
*/
|
|
42
|
+
function callSites(file: string): { line: number; window: string }[] {
|
|
43
|
+
const lines = readFileSync(file, "utf-8").split(/\r?\n/);
|
|
44
|
+
const hits: { line: number; window: string }[] = [];
|
|
45
|
+
lines.forEach((raw, i) => {
|
|
46
|
+
const code = raw.replace(/\/\/.*$/, "");
|
|
47
|
+
if (RAW_SEND.test(code)) hits.push({ line: i + 1, window: code + (lines[i + 1] ?? "") });
|
|
48
|
+
});
|
|
49
|
+
return hits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("every raw-input producer declares itself", () => {
|
|
53
|
+
const files = ROOTS.flatMap((r) => sources(r));
|
|
54
|
+
|
|
55
|
+
it('no /api/send call uses code:"none" without raw:true', () => {
|
|
56
|
+
const offenders = files.flatMap((f) =>
|
|
57
|
+
callSites(f)
|
|
58
|
+
.filter((h) => !/raw:\s*true/.test(h.window))
|
|
59
|
+
.map((h) => `${f}:${h.line}`),
|
|
60
|
+
);
|
|
61
|
+
expect(offenders).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("still finds the producers (a scan that matches nothing passes forever)", () => {
|
|
65
|
+
const total = files.reduce((n, f) => n + callSites(f).length, 0);
|
|
66
|
+
expect(total).toBeGreaterThanOrEqual(3);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
REMOTE_BACKOFF_BASE_MS,
|
|
4
|
+
REMOTE_BACKOFF_MAX_MS,
|
|
5
|
+
noteRemoteResult,
|
|
6
|
+
pruneRemoteHealth,
|
|
7
|
+
remoteBackoffMs,
|
|
8
|
+
shouldSkipRemote,
|
|
9
|
+
} from "./remoteHealth.ts";
|
|
10
|
+
|
|
11
|
+
describe("remoteHealth.remoteBackoffMs", () => {
|
|
12
|
+
it("doubles from the base and then holds at the cap", () => {
|
|
13
|
+
expect(remoteBackoffMs(1)).toBe(REMOTE_BACKOFF_BASE_MS);
|
|
14
|
+
expect(remoteBackoffMs(2)).toBe(REMOTE_BACKOFF_BASE_MS * 2);
|
|
15
|
+
expect(remoteBackoffMs(3)).toBe(REMOTE_BACKOFF_BASE_MS * 4);
|
|
16
|
+
expect(remoteBackoffMs(99)).toBe(REMOTE_BACKOFF_MAX_MS);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("is zero for a host that has not failed — and this GUARDS the clause below", () => {
|
|
20
|
+
// A streak of 0 must never produce a wait: that is the difference between
|
|
21
|
+
// "healthy" and "failed once", and getting it wrong skips a live host.
|
|
22
|
+
expect(remoteBackoffMs(0)).toBe(0);
|
|
23
|
+
expect(remoteBackoffMs(-1)).toBe(0);
|
|
24
|
+
|
|
25
|
+
// This is also the tripwire for `shouldSkipRemote`'s `streak <= 0` clause.
|
|
26
|
+
// That clause is UNREACHABLE while this holds, which is why no test can
|
|
27
|
+
// redden it directly — but change the curve so a non-positive streak yields
|
|
28
|
+
// a non-zero wait and the clause goes LIVE, with nothing behind it, at the
|
|
29
|
+
// exact moment someone is editing the thing it guards. So the invariant is
|
|
30
|
+
// pinned HERE, where it is reachable and can fail loudly, instead.
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not overflow on an absurd streak", () => {
|
|
34
|
+
// 2 ** 5000 is Infinity; the shift clamp is what keeps this finite.
|
|
35
|
+
expect(Number.isFinite(remoteBackoffMs(5000))).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("remoteHealth.shouldSkipRemote", () => {
|
|
40
|
+
const NOW = 1_000_000_000;
|
|
41
|
+
|
|
42
|
+
it("never skips a host with no record — the control that matters", () => {
|
|
43
|
+
// Wrongly skipping makes an agent silently vanish from `ay ls`; wrongly
|
|
44
|
+
// probing costs one slow list. Those are not symmetric, so the tie goes to
|
|
45
|
+
// probing, and an unknown host is always probed.
|
|
46
|
+
expect(shouldSkipRemote(undefined, NOW)).toBe(false);
|
|
47
|
+
// A cleared host carrying a stale timestamp — what `noteRemoteResult`
|
|
48
|
+
// produces on success — must also never be skipped. This asserts the
|
|
49
|
+
// BEHAVIOUR; it does not isolate the `streak <= 0` clause, which is
|
|
50
|
+
// redundant with `remoteBackoffMs(0) === 0` and cannot be reddened on its
|
|
51
|
+
// own. Said plainly rather than left looking like coverage it is not.
|
|
52
|
+
expect(shouldSkipRemote({ streak: 0, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
|
|
53
|
+
expect(shouldSkipRemote({ streak: -3, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("skips inside the backoff window and probes again once it elapses", () => {
|
|
57
|
+
const h = { streak: 1, lastFailedAt: NOW };
|
|
58
|
+
expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS - 1)).toBe(true);
|
|
59
|
+
expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("probes rather than guesses when the clock has moved backwards", () => {
|
|
63
|
+
// A backwards clock makes `elapsed` negative, which would otherwise read as
|
|
64
|
+
// "still inside the window" and skip the host until the clock caught up.
|
|
65
|
+
expect(shouldSkipRemote({ streak: 3, lastFailedAt: NOW }, NOW - 60_000)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("remoteHealth.readRemoteHealth validation", () => {
|
|
70
|
+
it("ignores an entry whose shape it cannot trust", async () => {
|
|
71
|
+
// The file is derived data in a writable path. A valid-JSON record with a
|
|
72
|
+
// plausible-looking streak must not be able to hide a live agent, so
|
|
73
|
+
// anything that is not two finite numbers is dropped rather than trusted.
|
|
74
|
+
const { mkdtemp, writeFile } = await import("fs/promises");
|
|
75
|
+
const { tmpdir } = await import("os");
|
|
76
|
+
const path = (await import("path")).default;
|
|
77
|
+
const dir = await mkdtemp(path.join(tmpdir(), "ay-health-"));
|
|
78
|
+
const saved = process.env.AGENT_YES_HOME;
|
|
79
|
+
process.env.AGENT_YES_HOME = dir;
|
|
80
|
+
try {
|
|
81
|
+
await writeFile(
|
|
82
|
+
path.join(dir, "remote-health.json"),
|
|
83
|
+
JSON.stringify({
|
|
84
|
+
good: { streak: 2, lastFailedAt: 123 },
|
|
85
|
+
missingField: { streak: 2 },
|
|
86
|
+
wrongType: { streak: "9", lastFailedAt: 1 },
|
|
87
|
+
notFinite: { streak: Number.POSITIVE_INFINITY, lastFailedAt: 1 },
|
|
88
|
+
notAnObject: 7,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
const { readRemoteHealth } = await import("./remoteHealth.ts");
|
|
92
|
+
expect(await readRemoteHealth()).toEqual({ good: { streak: 2, lastFailedAt: 123 } });
|
|
93
|
+
} finally {
|
|
94
|
+
if (saved === undefined) delete process.env.AGENT_YES_HOME;
|
|
95
|
+
else process.env.AGENT_YES_HOME = saved;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("remoteHealth.pruneRemoteHealth", () => {
|
|
101
|
+
it("drops aliases no longer configured, so the file cannot grow forever", () => {
|
|
102
|
+
const h = {
|
|
103
|
+
live: { streak: 1, lastFailedAt: 5 },
|
|
104
|
+
removed: { streak: 9, lastFailedAt: 5 },
|
|
105
|
+
};
|
|
106
|
+
expect(pruneRemoteHealth(h, ["live"])).toEqual({ live: { streak: 1, lastFailedAt: 5 } });
|
|
107
|
+
expect(pruneRemoteHealth(h, [])).toEqual({});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("remoteHealth.noteRemoteResult", () => {
|
|
112
|
+
const NOW = 1_000_000_000;
|
|
113
|
+
|
|
114
|
+
it("clears the streak completely on success", () => {
|
|
115
|
+
// A host that comes back must return to full speed immediately, not decay
|
|
116
|
+
// through the backoff it earned while it was down.
|
|
117
|
+
expect(noteRemoteResult({ streak: 9, lastFailedAt: NOW }, true, NOW + 1)).toEqual({
|
|
118
|
+
streak: 0,
|
|
119
|
+
lastFailedAt: 0,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("extends the streak on failure, from no record and from an existing one", () => {
|
|
124
|
+
expect(noteRemoteResult(undefined, false, NOW)).toEqual({ streak: 1, lastFailedAt: NOW });
|
|
125
|
+
expect(noteRemoteResult({ streak: 2, lastFailedAt: 1 }, false, NOW)).toEqual({
|
|
126
|
+
streak: 3,
|
|
127
|
+
lastFailedAt: NOW,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
});
|