@yeaft/webchat-agent 1.0.347 → 1.0.348
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/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +79 -79
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +1 -1
- package/package.json +1 -1
- package/yeaft/debug-trace.js +392 -103
- package/yeaft/session.js +3 -14
- package/yeaft/web-bridge.js +7 -1
- package/yeaft/work-center/bridge.js +1 -0
- package/yeaft/work-center/runner.js +1 -1
package/yeaft/debug-trace.js
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* debug-trace.js — file-backed debug trace for Yeaft
|
|
3
3
|
*
|
|
4
|
-
* Stores bounded request traces on disk without SQLite.
|
|
5
|
-
*
|
|
6
|
-
* <yeaftDir>/sessions/<sessionId>/debug/requests/<requestKey>/
|
|
4
|
+
* Stores bounded request traces on disk without SQLite. New requests use a
|
|
5
|
+
* small `meta.json` plus append-only `events.jsonl` under:
|
|
6
|
+
* <yeaftDir>/sessions/<sessionId>/debug/requests/<requestKey>/
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* and dropped, never allowed to stop the agent.
|
|
8
|
+
* Loop requests remain base-plus-delta records, so cumulative messages are not
|
|
9
|
+
* repeated. Legacy `trace.json` requests stay readable. Debug history is
|
|
10
|
+
* best-effort: failures are logged and never allowed to stop the agent.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
import { promises as fsp } from 'fs';
|
|
15
14
|
import { basename, dirname, extname, join } from 'path';
|
|
16
|
-
import { randomUUID } from 'crypto';
|
|
15
|
+
import { createHash, randomUUID } from 'crypto';
|
|
17
16
|
|
|
18
|
-
const TRACE_VERSION =
|
|
17
|
+
const TRACE_VERSION = 3;
|
|
19
18
|
const REQUEST_RETENTION = 10;
|
|
20
19
|
const MAX_HISTORY_LIMIT = 5;
|
|
21
20
|
const MAX_DREAM_EVENTS = 100;
|
|
@@ -31,8 +30,8 @@ const MAX_RAW_REQUEST_BYTES = 2 * 1024 * 1024;
|
|
|
31
30
|
// A 64 KiB prefix is enough to inspect provider envelopes without letting
|
|
32
31
|
// always-on diagnostics dominate runtime latency.
|
|
33
32
|
const MAX_RAW_RESPONSE_BYTES = 64 * 1024;
|
|
34
|
-
const
|
|
35
|
-
const
|
|
33
|
+
const TRACE_APPEND_BATCH_MS = 100;
|
|
34
|
+
const EVENT_FLUSH_INTERVAL_MS = 30_000;
|
|
36
35
|
const MAX_SEARCH_PATTERN_CHARS = 300;
|
|
37
36
|
|
|
38
37
|
function isPlainObject(value) {
|
|
@@ -45,6 +44,15 @@ function safeDirComponent(value, fallback = 'unknown') {
|
|
|
45
44
|
return raw.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120) || fallback;
|
|
46
45
|
}
|
|
47
46
|
|
|
47
|
+
function storageDirComponent(value, fallback = 'unknown') {
|
|
48
|
+
const raw = String(value || '').trim();
|
|
49
|
+
if (!raw) return fallback;
|
|
50
|
+
if (/^[a-zA-Z0-9._-]+$/.test(raw) && Buffer.byteLength(raw, 'utf8') <= 200) return raw;
|
|
51
|
+
const prefix = raw.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || fallback;
|
|
52
|
+
const digest = createHash('sha256').update(raw).digest('hex');
|
|
53
|
+
return `${prefix}-${digest}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
function fileTraceRoot(inputPath) {
|
|
49
57
|
const raw = String(inputPath || '').trim();
|
|
50
58
|
if (!raw) return null;
|
|
@@ -456,16 +464,125 @@ export function reconstructDebugRawRequest(baseRawRequest, requestDelta) {
|
|
|
456
464
|
}
|
|
457
465
|
|
|
458
466
|
function sessionRequestsDir(rootDir, sessionId) {
|
|
467
|
+
if (sessionId) return join(rootDir, 'sessions', storageDirComponent(sessionId, 'session'), 'debug', 'requests');
|
|
468
|
+
return join(rootDir, 'debug', 'requests');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function legacySessionRequestsDir(rootDir, sessionId) {
|
|
459
472
|
if (sessionId) return join(rootDir, 'sessions', safeDirComponent(sessionId), 'debug', 'requests');
|
|
460
473
|
return join(rootDir, 'debug', 'requests');
|
|
461
474
|
}
|
|
462
475
|
|
|
476
|
+
function sessionRequestDirs(rootDir, sessionId) {
|
|
477
|
+
const current = sessionRequestsDir(rootDir, sessionId);
|
|
478
|
+
const legacy = legacySessionRequestsDir(rootDir, sessionId);
|
|
479
|
+
return current === legacy ? [current] : [current, legacy];
|
|
480
|
+
}
|
|
481
|
+
|
|
463
482
|
function requestFilePath(requestDir) {
|
|
464
483
|
return join(requestDir, 'trace.json');
|
|
465
484
|
}
|
|
466
485
|
|
|
486
|
+
function requestMetaPath(requestDir) {
|
|
487
|
+
return join(requestDir, 'meta.json');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function requestEventsPath(requestDir) {
|
|
491
|
+
return join(requestDir, 'events.jsonl');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function requestDirFor(rootDir, sessionId, requestKey) {
|
|
495
|
+
return join(sessionRequestsDir(rootDir, sessionId), storageDirComponent(requestKey, 'request'));
|
|
496
|
+
}
|
|
497
|
+
|
|
467
498
|
function tracePathFor(rootDir, sessionId, requestKey) {
|
|
468
|
-
return requestFilePath(
|
|
499
|
+
return requestFilePath(requestDirFor(rootDir, sessionId, requestKey));
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function turnLocatorName(turnId) {
|
|
503
|
+
const raw = String(turnId || 'turn');
|
|
504
|
+
const digest = createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
505
|
+
return `${safeDirComponent(raw, 'turn')}-${digest}.json`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function turnLocatorPath(rootDir, sessionId, turnId) {
|
|
509
|
+
return join(sessionRequestsDir(rootDir, sessionId), '..', 'turns', turnLocatorName(turnId));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function serializableTraceMeta(trace) {
|
|
513
|
+
const meta = { ...trace };
|
|
514
|
+
delete meta._lastSnapshot;
|
|
515
|
+
delete meta._persistedFormat;
|
|
516
|
+
delete meta._persistedRequestDir;
|
|
517
|
+
delete meta.baseRequest;
|
|
518
|
+
delete meta.loops;
|
|
519
|
+
delete meta.tools;
|
|
520
|
+
return meta;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function traceMatchesIdentity(trace, sessionId, turnId) {
|
|
524
|
+
return !!trace
|
|
525
|
+
&& trace.sessionId === sessionId
|
|
526
|
+
&& (trace.requestId === turnId || trace.traceId === turnId);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function prepareJsonlAppend(filePath) {
|
|
530
|
+
let text;
|
|
531
|
+
try { text = await fsp.readFile(filePath, 'utf8'); }
|
|
532
|
+
catch { return; }
|
|
533
|
+
if (!text || text.endsWith('\n')) return;
|
|
534
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
535
|
+
const tail = text.slice(lastNewline + 1);
|
|
536
|
+
try {
|
|
537
|
+
JSON.parse(tail);
|
|
538
|
+
await fsp.appendFile(filePath, '\n', 'utf8');
|
|
539
|
+
} catch {
|
|
540
|
+
const validPrefix = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : '';
|
|
541
|
+
await fsp.truncate(filePath, Buffer.byteLength(validPrefix, 'utf8'));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function readJsonLines(filePath) {
|
|
546
|
+
let text;
|
|
547
|
+
try { text = await fsp.readFile(filePath, 'utf8'); }
|
|
548
|
+
catch { return []; }
|
|
549
|
+
const records = [];
|
|
550
|
+
for (const line of text.split('\n')) {
|
|
551
|
+
if (!line.trim()) continue;
|
|
552
|
+
try { records.push(JSON.parse(line)); }
|
|
553
|
+
catch { /* A process can leave one torn final append; prior records remain valid. */ }
|
|
554
|
+
}
|
|
555
|
+
return records;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function readRequestDir(requestDir) {
|
|
559
|
+
const meta = await readJson(requestMetaPath(requestDir));
|
|
560
|
+
if (!meta?.requestId) {
|
|
561
|
+
const legacy = await readJson(requestFilePath(requestDir));
|
|
562
|
+
return legacy ? { ...legacy, _persistedFormat: 'legacy', _persistedRequestDir: requestDir } : null;
|
|
563
|
+
}
|
|
564
|
+
const trace = { ...meta, _persistedFormat: 'events', _persistedRequestDir: requestDir, baseRequest: meta.legacyBaseRequest || null, loops: [], tools: [] };
|
|
565
|
+
delete trace.legacyBaseRequest;
|
|
566
|
+
const loopById = new Map();
|
|
567
|
+
const toolById = new Map();
|
|
568
|
+
for (const event of await readJsonLines(requestEventsPath(requestDir))) {
|
|
569
|
+
const record = event?.record;
|
|
570
|
+
if (event?.type === 'loop' && record) {
|
|
571
|
+
const key = record.turnRowId || record.loopInstanceId || `${record.loopNumber || 0}`;
|
|
572
|
+
loopById.set(key, record);
|
|
573
|
+
} else if (event?.type === 'tool' && record) {
|
|
574
|
+
const key = record.id || `${record.turnRowId || ''}:${record.toolCallId || ''}`;
|
|
575
|
+
toolById.set(key, record);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
trace.loops = Array.from(loopById.values()).sort((a, b) => (a.loopNumber || 0) - (b.loopNumber || 0) || String(a.turnRowId || '').localeCompare(String(b.turnRowId || '')));
|
|
579
|
+
trace.tools = Array.from(toolById.values()).sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
580
|
+
if (trace.loops.length > 0) {
|
|
581
|
+
const last = trace.loops.at(-1);
|
|
582
|
+
trace.closedAt = meta.closedAt || last.at || null;
|
|
583
|
+
trace.updatedAt = Math.max(Number(meta.updatedAt || 0), Number(last.at || 0));
|
|
584
|
+
}
|
|
585
|
+
return trace;
|
|
469
586
|
}
|
|
470
587
|
|
|
471
588
|
function summarizeTrace(trace, detailsLoaded = false) {
|
|
@@ -595,21 +712,18 @@ async function readdirSafe(dir) {
|
|
|
595
712
|
catch { return []; }
|
|
596
713
|
}
|
|
597
714
|
|
|
598
|
-
async function
|
|
599
|
-
const
|
|
715
|
+
async function collectRequestDirs(rootDir, sessionId = null) {
|
|
716
|
+
const dirs = [];
|
|
600
717
|
const addFromRequestsDir = async (requestsDir) => {
|
|
601
718
|
for (const entry of await readdirSafe(requestsDir)) {
|
|
602
|
-
if (
|
|
603
|
-
const file = requestFilePath(join(requestsDir, entry.name));
|
|
604
|
-
try {
|
|
605
|
-
await fsp.access(file);
|
|
606
|
-
files.push(file);
|
|
607
|
-
} catch { /* trace.json not yet written for this request dir */ }
|
|
719
|
+
if (entry.isDirectory()) dirs.push(join(requestsDir, entry.name));
|
|
608
720
|
}
|
|
609
721
|
};
|
|
610
722
|
if (sessionId) {
|
|
611
|
-
|
|
612
|
-
|
|
723
|
+
for (const requestsDir of sessionRequestDirs(rootDir, sessionId)) {
|
|
724
|
+
await addFromRequestsDir(requestsDir);
|
|
725
|
+
}
|
|
726
|
+
return dirs;
|
|
613
727
|
}
|
|
614
728
|
await addFromRequestsDir(sessionRequestsDir(rootDir, null));
|
|
615
729
|
const sessionsRoot = join(rootDir, 'sessions');
|
|
@@ -617,20 +731,71 @@ async function collectTraceFiles(rootDir, sessionId = null) {
|
|
|
617
731
|
if (!entry.isDirectory()) continue;
|
|
618
732
|
await addFromRequestsDir(join(sessionsRoot, entry.name, 'debug', 'requests'));
|
|
619
733
|
}
|
|
620
|
-
return
|
|
734
|
+
return dirs;
|
|
621
735
|
}
|
|
622
736
|
|
|
623
737
|
async function readTraceSummaries(rootDir, sessionId = null) {
|
|
624
738
|
const traces = [];
|
|
625
|
-
for (const
|
|
626
|
-
const trace = await
|
|
739
|
+
for (const requestDir of await collectRequestDirs(rootDir, sessionId)) {
|
|
740
|
+
const trace = await readRequestDir(requestDir);
|
|
627
741
|
if (!trace || !trace.requestId) continue;
|
|
628
|
-
|
|
742
|
+
if (sessionId && trace.sessionId !== sessionId) continue;
|
|
743
|
+
const file = requestFilePath(requestDir);
|
|
744
|
+
traces.push({ trace, file, requestDir, openedAt: Number(trace.openedAt || 0) });
|
|
629
745
|
}
|
|
630
746
|
traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace?.requestKey || a.file).localeCompare(String(b.trace?.requestKey || b.file)));
|
|
631
747
|
return traces;
|
|
632
748
|
}
|
|
633
749
|
|
|
750
|
+
async function readTraceIdentity(requestDir) {
|
|
751
|
+
const meta = await readJson(requestMetaPath(requestDir));
|
|
752
|
+
if (meta?.requestId) return meta;
|
|
753
|
+
return readJson(requestFilePath(requestDir));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function sameTraceIdentity(trace, expected) {
|
|
757
|
+
return !!trace && !!expected
|
|
758
|
+
&& trace.sessionId === expected.sessionId
|
|
759
|
+
&& trace.requestKey === expected.requestKey
|
|
760
|
+
&& trace.requestId === expected.requestId;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function removeRequestDirIfIdentityMatches(requestDir, expected) {
|
|
764
|
+
const identity = await readTraceIdentity(requestDir);
|
|
765
|
+
if (!sameTraceIdentity(identity, expected)) return false;
|
|
766
|
+
try {
|
|
767
|
+
await fsp.rm(requestDir, { recursive: true, force: true });
|
|
768
|
+
return true;
|
|
769
|
+
} catch {
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async function readTraceHeaders(rootDir, sessionId) {
|
|
775
|
+
const traces = [];
|
|
776
|
+
const requestDirs = sessionId == null
|
|
777
|
+
? await (async () => {
|
|
778
|
+
const dirs = [];
|
|
779
|
+
for (const entry of await readdirSafe(sessionRequestsDir(rootDir, null))) {
|
|
780
|
+
if (entry.isDirectory()) dirs.push(join(sessionRequestsDir(rootDir, null), entry.name));
|
|
781
|
+
}
|
|
782
|
+
return dirs;
|
|
783
|
+
})()
|
|
784
|
+
: await collectRequestDirs(rootDir, sessionId);
|
|
785
|
+
for (const requestDir of requestDirs) {
|
|
786
|
+
const meta = await readJson(requestMetaPath(requestDir));
|
|
787
|
+
const trace = meta?.requestId ? meta : await readJson(requestFilePath(requestDir));
|
|
788
|
+
if (!trace?.requestId || !trace?.requestKey || trace.sessionId !== sessionId) continue;
|
|
789
|
+
traces.push({
|
|
790
|
+
trace,
|
|
791
|
+
file: requestFilePath(requestDir),
|
|
792
|
+
requestDir,
|
|
793
|
+
openedAt: Number(trace.openedAt || 0),
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return traces.sort((a, b) => ((a.openedAt || 0) - (b.openedAt || 0)) || String(a.trace.requestKey).localeCompare(String(b.trace.requestKey)));
|
|
797
|
+
}
|
|
798
|
+
|
|
634
799
|
async function countDirFiles(rootDir) {
|
|
635
800
|
let files = 0;
|
|
636
801
|
let bytes = 0;
|
|
@@ -655,10 +820,16 @@ export class DebugTrace {
|
|
|
655
820
|
#turnIndex = new Map();
|
|
656
821
|
/** @type {Map<string, object>} */
|
|
657
822
|
#requestCache = new Map();
|
|
658
|
-
/** @type {
|
|
659
|
-
#pendingWrites =
|
|
823
|
+
/** @type {Array<{ trace: object, type: 'loop'|'tool', record: object, initialize: boolean, writeMeta: boolean }>} */
|
|
824
|
+
#pendingWrites = [];
|
|
825
|
+
/** @type {Set<string>} */
|
|
826
|
+
#initializedRequestKeys = new Set();
|
|
827
|
+
/** @type {Set<string>} */
|
|
828
|
+
#reconciledRetentionSessions = new Set();
|
|
829
|
+
/** @type {Map<string, Map<string, { trace: object, requestDirs: Set<string>, openedAt: number }>>} */
|
|
830
|
+
#retentionIndex = new Map();
|
|
660
831
|
/** @type {NodeJS.Timeout|null} */
|
|
661
|
-
#
|
|
832
|
+
#appendTimer = null;
|
|
662
833
|
/** @type {number} */
|
|
663
834
|
#sequence = 0;
|
|
664
835
|
/**
|
|
@@ -693,6 +864,8 @@ export class DebugTrace {
|
|
|
693
864
|
* @type {Promise<void>}
|
|
694
865
|
*/
|
|
695
866
|
#flushChain = Promise.resolve();
|
|
867
|
+
/** @type {boolean} */
|
|
868
|
+
#acceptingWrites = true;
|
|
696
869
|
|
|
697
870
|
/**
|
|
698
871
|
* @param {string} tracePath — Back-compatible path. If it looks like a DB
|
|
@@ -708,6 +881,7 @@ export class DebugTrace {
|
|
|
708
881
|
}
|
|
709
882
|
|
|
710
883
|
startTurn({ traceId, messageId = null, mode = null, turnNumber = null, sessionId = null, vpId = null, threadId = null, userPrompt = null } = {}) {
|
|
884
|
+
if (!this.#acceptingWrites) return 'null';
|
|
711
885
|
const turnRowId = randomUUID();
|
|
712
886
|
const now = Date.now();
|
|
713
887
|
const request = this.#getOrCreateRequest({
|
|
@@ -777,7 +951,7 @@ export class DebugTrace {
|
|
|
777
951
|
trace.updatedAt = loop.at;
|
|
778
952
|
trace.active = info.stopReason ? !['end_turn', 'error', 'aborted'].includes(String(info.stopReason)) : false;
|
|
779
953
|
trace._lastSnapshot = snapshot;
|
|
780
|
-
this.#
|
|
954
|
+
this.#appendTraceRecord(trace, 'loop', loop, { writeMeta: !trace.active });
|
|
781
955
|
}
|
|
782
956
|
|
|
783
957
|
logTool(turnId, { toolName, toolCallId = null, toolInput = null, toolOutput = null, durationMs = null, isError = false } = {}) {
|
|
@@ -787,7 +961,7 @@ export class DebugTrace {
|
|
|
787
961
|
const trace = this.#loadRequest(ctx.sessionId, ctx.requestKey);
|
|
788
962
|
if (!trace) return id;
|
|
789
963
|
if (!Array.isArray(trace.tools)) trace.tools = [];
|
|
790
|
-
|
|
964
|
+
const tool = {
|
|
791
965
|
id,
|
|
792
966
|
turnRowId: turnId,
|
|
793
967
|
loopNumber: ctx.loopNumber || 0,
|
|
@@ -798,14 +972,16 @@ export class DebugTrace {
|
|
|
798
972
|
durationMs: Number(durationMs || 0),
|
|
799
973
|
isError: !!isError,
|
|
800
974
|
createdAt: Date.now(),
|
|
801
|
-
}
|
|
802
|
-
trace.
|
|
803
|
-
|
|
975
|
+
};
|
|
976
|
+
trace.tools.push(tool);
|
|
977
|
+
trace.updatedAt = tool.createdAt;
|
|
978
|
+
this.#appendTraceRecord(trace, 'tool', tool, { writeMeta: false });
|
|
804
979
|
return id;
|
|
805
980
|
}
|
|
806
981
|
|
|
807
982
|
logEvent({ traceId, eventType, eventData = null } = {}) {
|
|
808
983
|
const id = randomUUID();
|
|
984
|
+
if (!this.#acceptingWrites) return id;
|
|
809
985
|
// In-memory authoritative ring; persisted async (fire-and-forget). The
|
|
810
986
|
// dream/event sink runs on the engine hot path, so it must not block on a
|
|
811
987
|
// synchronous read-modify-write of events.json.
|
|
@@ -858,11 +1034,54 @@ export class DebugTrace {
|
|
|
858
1034
|
.flatMap(({ trace }) => traceToLegacyRows(trace));
|
|
859
1035
|
}
|
|
860
1036
|
|
|
1037
|
+
async fetchTurnDebug({ sessionId, turnId, dreamLimit = 0 } = {}) {
|
|
1038
|
+
const requestedSessionId = typeof sessionId === 'string' && sessionId ? sessionId : null;
|
|
1039
|
+
const requestedTurnId = typeof turnId === 'string' && turnId ? turnId : null;
|
|
1040
|
+
if (!requestedSessionId || !requestedTurnId) {
|
|
1041
|
+
return { loops: [], turns: [], dreamEvents: [], detailTurnId: requestedTurnId };
|
|
1042
|
+
}
|
|
1043
|
+
await this.#drainWrites();
|
|
1044
|
+
|
|
1045
|
+
let trace = Array.from(this.#requestCache.values()).find(item => (
|
|
1046
|
+
item?.sessionId === requestedSessionId
|
|
1047
|
+
&& (item.requestId === requestedTurnId || item.traceId === requestedTurnId)
|
|
1048
|
+
)) || null;
|
|
1049
|
+
if (!trace) {
|
|
1050
|
+
const locator = await readJson(turnLocatorPath(this.#rootDir, requestedSessionId, requestedTurnId));
|
|
1051
|
+
if (locator?.requestKey && locator.sessionId === requestedSessionId && locator.requestId === requestedTurnId) {
|
|
1052
|
+
const located = await readRequestDir(requestDirFor(this.#rootDir, requestedSessionId, locator.requestKey));
|
|
1053
|
+
if (traceMatchesIdentity(located, requestedSessionId, requestedTurnId)) {
|
|
1054
|
+
trace = located;
|
|
1055
|
+
this.#requestCache.set(trace.requestKey, trace);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
if (!trace) {
|
|
1060
|
+
// Legacy v2 traces have no locator. Scan only as a compatibility fallback;
|
|
1061
|
+
// every v3 trace takes the direct sessionId + turnId path above.
|
|
1062
|
+
for (const item of await readTraceSummaries(this.#rootDir, requestedSessionId)) {
|
|
1063
|
+
if (traceMatchesIdentity(item.trace, requestedSessionId, requestedTurnId)) {
|
|
1064
|
+
trace = item.trace;
|
|
1065
|
+
this.#requestCache.set(trace.requestKey, trace);
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
const dreamEvents = this.#readDreamEvents({ sessionId: requestedSessionId, dreamLimit });
|
|
1071
|
+
if (!trace) return { loops: [], turns: [], dreamEvents, detailTurnId: requestedTurnId };
|
|
1072
|
+
const expanded = expandTrace(trace);
|
|
1073
|
+
return { ...expanded, dreamEvents, detailTurnId: requestedTurnId };
|
|
1074
|
+
}
|
|
1075
|
+
|
|
861
1076
|
async fetchRecentDebugHistory({ limit = MAX_HISTORY_LIMIT, dreamLimit = 5, sessionId = null, threadId = null, indexOnly = false, detailTurnId = null, search = '' } = {}) {
|
|
1077
|
+
const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
|
|
1078
|
+
if (requestedDetailTurnId && sessionId) {
|
|
1079
|
+
const detail = await this.fetchTurnDebug({ sessionId, turnId: requestedDetailTurnId, dreamLimit });
|
|
1080
|
+
return { ...detail, hasMore: false, limit: detail.loops.length, indexOnly: false };
|
|
1081
|
+
}
|
|
862
1082
|
await this.#ensureHydrated();
|
|
863
1083
|
await this.#drainWrites();
|
|
864
1084
|
const lim = Math.max(1, Math.min(MAX_HISTORY_LIMIT, Number(limit) || MAX_HISTORY_LIMIT));
|
|
865
|
-
const requestedDetailTurnId = typeof detailTurnId === 'string' && detailTurnId ? detailTurnId : null;
|
|
866
1085
|
const searchRegex = requestedDetailTurnId ? null : compileTraceSearchRegex(search);
|
|
867
1086
|
const traces = this.#traceSummaries(sessionId)
|
|
868
1087
|
.filter(({ trace }) => !threadId || trace.threadId === threadId)
|
|
@@ -953,20 +1172,25 @@ export class DebugTrace {
|
|
|
953
1172
|
}
|
|
954
1173
|
|
|
955
1174
|
async purge() {
|
|
1175
|
+
this.#acceptingWrites = false;
|
|
956
1176
|
await this.#drainWrites();
|
|
957
1177
|
try { await fsp.rm(this.#rootDir, { recursive: true, force: true }); }
|
|
958
1178
|
catch { /* ignore */ }
|
|
959
1179
|
await ensureDir(this.#rootDir);
|
|
960
1180
|
this.#turnIndex.clear();
|
|
961
1181
|
this.#requestCache.clear();
|
|
1182
|
+
this.#initializedRequestKeys.clear();
|
|
1183
|
+
this.#reconciledRetentionSessions.clear();
|
|
1184
|
+
this.#retentionIndex.clear();
|
|
962
1185
|
this.#events = [];
|
|
963
1186
|
this.#hydrated = false;
|
|
964
1187
|
this.#hydratePromise = null;
|
|
965
1188
|
this.#eventsHydrated = false;
|
|
1189
|
+
this.#acceptingWrites = true;
|
|
966
1190
|
}
|
|
967
1191
|
|
|
968
1192
|
async close() {
|
|
969
|
-
|
|
1193
|
+
this.#acceptingWrites = false;
|
|
970
1194
|
await this.#drainWrites();
|
|
971
1195
|
}
|
|
972
1196
|
|
|
@@ -1104,47 +1328,32 @@ export class DebugTrace {
|
|
|
1104
1328
|
this.#events = [...older, ...this.#events].slice(-MAX_DREAM_EVENTS);
|
|
1105
1329
|
}
|
|
1106
1330
|
|
|
1107
|
-
#traceWriteKey(trace) {
|
|
1108
|
-
return `${trace.sessionId || ''}::${trace.requestKey}`;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
1331
|
#traceFile(trace) {
|
|
1112
1332
|
return tracePathFor(this.#rootDir, trace.sessionId || null, trace.requestKey);
|
|
1113
1333
|
}
|
|
1114
1334
|
|
|
1115
|
-
#
|
|
1116
|
-
|
|
1117
|
-
delete toWrite._lastSnapshot;
|
|
1118
|
-
return toWrite;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
#markDirty(trace, { dirtyLoops = 0, force = false } = {}) {
|
|
1122
|
-
if (!trace?.requestKey) return;
|
|
1123
|
-
const key = this.#traceWriteKey(trace);
|
|
1124
|
-
const existing = this.#pendingWrites.get(key);
|
|
1125
|
-
const now = Date.now();
|
|
1126
|
-
const item = existing || { trace, dirtyLoops: 0, firstDirtyAt: now };
|
|
1127
|
-
item.trace = trace;
|
|
1128
|
-
item.dirtyLoops += Math.max(0, Number(dirtyLoops) || 0);
|
|
1129
|
-
this.#pendingWrites.set(key, item);
|
|
1335
|
+
#appendTraceRecord(trace, type, record, { writeMeta = false } = {}) {
|
|
1336
|
+
if (!this.#acceptingWrites || !trace?.requestKey || !record) return;
|
|
1130
1337
|
this.#requestCache.set(trace.requestKey, trace);
|
|
1131
|
-
|
|
1132
|
-
if (
|
|
1338
|
+
const initialize = !this.#initializedRequestKeys.has(trace.requestKey);
|
|
1339
|
+
if (initialize) this.#initializedRequestKeys.add(trace.requestKey);
|
|
1340
|
+
this.#pendingWrites.push({
|
|
1341
|
+
trace,
|
|
1342
|
+
type,
|
|
1343
|
+
record: cloneJsonValue(record),
|
|
1344
|
+
initialize,
|
|
1345
|
+
writeMeta: !!writeMeta,
|
|
1346
|
+
});
|
|
1347
|
+
if (writeMeta) {
|
|
1133
1348
|
this.#flushPending();
|
|
1134
1349
|
return;
|
|
1135
1350
|
}
|
|
1136
|
-
this.#
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
#scheduleFlushTimer(now = Date.now()) {
|
|
1140
|
-
if (this.#flushTimer || this.#pendingWrites.size === 0) return;
|
|
1141
|
-
const oldestDirtyAt = Math.min(...Array.from(this.#pendingWrites.values()).map(item => item.firstDirtyAt || now));
|
|
1142
|
-
const dueIn = Math.max(0, TRACE_FLUSH_INTERVAL_MS - (now - oldestDirtyAt));
|
|
1143
|
-
this.#flushTimer = setTimeout(() => {
|
|
1144
|
-
this.#flushTimer = null;
|
|
1351
|
+
if (this.#appendTimer) return;
|
|
1352
|
+
this.#appendTimer = setTimeout(() => {
|
|
1353
|
+
this.#appendTimer = null;
|
|
1145
1354
|
this.#flushPending();
|
|
1146
|
-
},
|
|
1147
|
-
if (typeof this.#
|
|
1355
|
+
}, TRACE_APPEND_BATCH_MS);
|
|
1356
|
+
if (typeof this.#appendTimer.unref === 'function') this.#appendTimer.unref();
|
|
1148
1357
|
}
|
|
1149
1358
|
|
|
1150
1359
|
#scheduleEventFlush() {
|
|
@@ -1153,43 +1362,73 @@ export class DebugTrace {
|
|
|
1153
1362
|
this.#eventFlushTimer = setTimeout(() => {
|
|
1154
1363
|
this.#eventFlushTimer = null;
|
|
1155
1364
|
this.#flushEvents();
|
|
1156
|
-
},
|
|
1365
|
+
}, EVENT_FLUSH_INTERVAL_MS);
|
|
1157
1366
|
if (typeof this.#eventFlushTimer.unref === 'function') this.#eventFlushTimer.unref();
|
|
1158
1367
|
}
|
|
1159
1368
|
|
|
1160
|
-
/**
|
|
1161
|
-
* Drain pending trace writes synchronously-from-the-caller's-view: snapshot
|
|
1162
|
-
* each dirty trace to a JSON STRING now (so later in-place mutation of the
|
|
1163
|
-
* live `loops` array can't tear the payload), then chain the async writes
|
|
1164
|
-
* onto #flushChain. Returns nothing; callers that need durability await
|
|
1165
|
-
* #drainWrites().
|
|
1166
|
-
*/
|
|
1369
|
+
/** Queue append-only loop/tool records onto the single-writer chain. */
|
|
1167
1370
|
#flushPending() {
|
|
1168
|
-
if (this.#
|
|
1169
|
-
clearTimeout(this.#
|
|
1170
|
-
this.#
|
|
1371
|
+
if (this.#appendTimer) {
|
|
1372
|
+
clearTimeout(this.#appendTimer);
|
|
1373
|
+
this.#appendTimer = null;
|
|
1171
1374
|
}
|
|
1172
|
-
const entries =
|
|
1375
|
+
const entries = this.#pendingWrites.splice(0);
|
|
1173
1376
|
if (entries.length === 0) {
|
|
1174
1377
|
this.#flushEvents();
|
|
1175
1378
|
return;
|
|
1176
1379
|
}
|
|
1177
|
-
this.#pendingWrites.clear();
|
|
1178
|
-
// Capture point-in-time payloads synchronously (so later in-place mutation
|
|
1179
|
-
// of the live `loops` array can't tear a payload), but remember each
|
|
1180
|
-
// request key: a prune chained ahead of this write may evict the request
|
|
1181
|
-
// before the write runs, and we must NOT resurrect a pruned trace on disk.
|
|
1182
|
-
const jobs = entries.map(({ trace }) => ({
|
|
1183
|
-
requestKey: trace.requestKey,
|
|
1184
|
-
file: this.#traceFile(trace),
|
|
1185
|
-
text: JSON.stringify(this.#serializableTrace(trace)),
|
|
1186
|
-
}));
|
|
1187
1380
|
this.#chain(async () => {
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
if (!this.#requestCache.has(requestKey)) continue;
|
|
1191
|
-
|
|
1192
|
-
|
|
1381
|
+
const batches = new Map();
|
|
1382
|
+
for (const entry of entries) {
|
|
1383
|
+
if (!this.#requestCache.has(entry.trace.requestKey)) continue;
|
|
1384
|
+
const requestDir = requestDirFor(this.#rootDir, entry.trace.sessionId || null, entry.trace.requestKey);
|
|
1385
|
+
const batch = batches.get(requestDir) || { trace: entry.trace, initialize: false, writeMeta: false, lines: [] };
|
|
1386
|
+
batch.trace = entry.trace;
|
|
1387
|
+
batch.initialize ||= entry.initialize;
|
|
1388
|
+
batch.writeMeta ||= entry.writeMeta;
|
|
1389
|
+
batch.lines.push(`${JSON.stringify({ type: entry.type, record: entry.record })}\n`);
|
|
1390
|
+
batches.set(requestDir, batch);
|
|
1391
|
+
}
|
|
1392
|
+
for (const [requestDir, batch] of batches) {
|
|
1393
|
+
const { trace, initialize, writeMeta } = batch;
|
|
1394
|
+
const lines = [...batch.lines];
|
|
1395
|
+
const legacyRequestDir = trace._persistedFormat === 'legacy'
|
|
1396
|
+
? trace._persistedRequestDir || null
|
|
1397
|
+
: null;
|
|
1398
|
+
try {
|
|
1399
|
+
const metaPath = requestMetaPath(requestDir);
|
|
1400
|
+
if (initialize) {
|
|
1401
|
+
const meta = serializableTraceMeta(trace);
|
|
1402
|
+
if (legacyRequestDir) {
|
|
1403
|
+
meta.legacyBaseRequest = cloneJsonValue(trace.baseRequest);
|
|
1404
|
+
const legacyLines = [];
|
|
1405
|
+
for (const loop of Array.isArray(trace.loops) ? trace.loops : []) {
|
|
1406
|
+
legacyLines.push(`${JSON.stringify({ type: 'loop', record: loop })}\n`);
|
|
1407
|
+
}
|
|
1408
|
+
for (const tool of Array.isArray(trace.tools) ? trace.tools : []) {
|
|
1409
|
+
legacyLines.push(`${JSON.stringify({ type: 'tool', record: tool })}\n`);
|
|
1410
|
+
}
|
|
1411
|
+
lines.unshift(...legacyLines);
|
|
1412
|
+
}
|
|
1413
|
+
await atomicWriteText(metaPath, JSON.stringify(meta));
|
|
1414
|
+
await atomicWriteText(
|
|
1415
|
+
turnLocatorPath(this.#rootDir, trace.sessionId || null, trace.requestId),
|
|
1416
|
+
JSON.stringify({ requestKey: trace.requestKey, requestId: trace.requestId, sessionId: trace.sessionId || null })
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
await ensureDir(requestDir);
|
|
1420
|
+
const eventPath = requestEventsPath(requestDir);
|
|
1421
|
+
if (initialize) await prepareJsonlAppend(eventPath);
|
|
1422
|
+
await fsp.appendFile(eventPath, lines.join(''), 'utf8');
|
|
1423
|
+
if (writeMeta) await atomicWriteText(metaPath, JSON.stringify(serializableTraceMeta(trace)));
|
|
1424
|
+
trace._persistedFormat = 'events';
|
|
1425
|
+
trace._persistedRequestDir = requestDir;
|
|
1426
|
+
if (legacyRequestDir && legacyRequestDir !== requestDir) {
|
|
1427
|
+
await removeRequestDirIfIdentityMatches(legacyRequestDir, trace);
|
|
1428
|
+
}
|
|
1429
|
+
} catch (err) {
|
|
1430
|
+
console.warn('[Yeaft] debug trace append failed:', err?.message || err);
|
|
1431
|
+
}
|
|
1193
1432
|
}
|
|
1194
1433
|
try { await this.#pruneAll(REQUEST_RETENTION); }
|
|
1195
1434
|
catch (err) { console.warn('[Yeaft] debug trace prune failed:', err?.message || err); }
|
|
@@ -1281,24 +1520,73 @@ export class DebugTrace {
|
|
|
1281
1520
|
}
|
|
1282
1521
|
|
|
1283
1522
|
async #pruneAll(keep) {
|
|
1284
|
-
|
|
1285
|
-
const sessions = new Set([null]);
|
|
1523
|
+
const sessions = new Set();
|
|
1286
1524
|
for (const trace of this.#requestCache.values()) sessions.add(trace.sessionId || null);
|
|
1287
|
-
for (const
|
|
1525
|
+
for (const sessionId of sessions) await this.#pruneSession(sessionId, keep);
|
|
1288
1526
|
}
|
|
1289
1527
|
|
|
1290
1528
|
async #pruneSession(sessionId, keep = REQUEST_RETENTION) {
|
|
1291
|
-
const
|
|
1529
|
+
const sessionKey = sessionId || '';
|
|
1530
|
+
let index = this.#retentionIndex.get(sessionKey);
|
|
1531
|
+
if (!this.#reconciledRetentionSessions.has(sessionKey)) {
|
|
1532
|
+
index = new Map();
|
|
1533
|
+
for (const item of await readTraceHeaders(this.#rootDir, sessionId)) {
|
|
1534
|
+
const existing = index.get(item.trace.requestKey);
|
|
1535
|
+
if (existing) {
|
|
1536
|
+
existing.requestDirs.add(item.requestDir);
|
|
1537
|
+
const currentDir = requestDirFor(this.#rootDir, sessionId, item.trace.requestKey);
|
|
1538
|
+
if (item.requestDir === currentDir || existing.trace?._persistedFormat === 'legacy') {
|
|
1539
|
+
existing.trace = item.trace;
|
|
1540
|
+
}
|
|
1541
|
+
existing.openedAt = Math.min(existing.openedAt, item.openedAt);
|
|
1542
|
+
} else {
|
|
1543
|
+
index.set(item.trace.requestKey, {
|
|
1544
|
+
trace: item.trace,
|
|
1545
|
+
requestDirs: new Set([item.requestDir]),
|
|
1546
|
+
openedAt: item.openedAt,
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
this.#retentionIndex.set(sessionKey, index);
|
|
1551
|
+
this.#reconciledRetentionSessions.add(sessionKey);
|
|
1552
|
+
}
|
|
1553
|
+
for (const trace of this.#requestCache.values()) {
|
|
1554
|
+
if ((trace.sessionId || null) !== sessionId) continue;
|
|
1555
|
+
const requestDir = requestDirFor(this.#rootDir, sessionId, trace.requestKey);
|
|
1556
|
+
const existing = index.get(trace.requestKey);
|
|
1557
|
+
index.set(trace.requestKey, {
|
|
1558
|
+
trace,
|
|
1559
|
+
requestDirs: new Set([...(existing?.requestDirs || []), requestDir]),
|
|
1560
|
+
openedAt: Math.min(Number(trace.openedAt || 0), existing?.openedAt ?? Infinity),
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
const traces = Array.from(index.values()).sort((a, b) => (
|
|
1564
|
+
(a.openedAt || 0) - (b.openedAt || 0)
|
|
1565
|
+
|| String(a.trace.requestKey).localeCompare(String(b.trace.requestKey))
|
|
1566
|
+
));
|
|
1292
1567
|
const activeCutoff = Date.now() - 6 * 60 * 60 * 1000;
|
|
1293
1568
|
const protectedItems = traces.filter(item => item.trace?.active && Number(item.trace?.updatedAt || 0) >= activeCutoff);
|
|
1294
1569
|
const pruneCandidates = traces.filter(item => !protectedItems.includes(item));
|
|
1295
1570
|
const stale = pruneCandidates.slice(0, Math.max(0, traces.length - protectedItems.length - keep));
|
|
1296
1571
|
for (const item of stale) {
|
|
1297
|
-
// Drop from cache first so subsequent queries never resurface it, even
|
|
1298
|
-
// if the async rm is still in flight or fails.
|
|
1299
1572
|
this.#requestCache.delete(item.trace.requestKey);
|
|
1300
|
-
|
|
1301
|
-
|
|
1573
|
+
this.#initializedRequestKeys.delete(item.trace.requestKey);
|
|
1574
|
+
index.delete(item.trace.requestKey);
|
|
1575
|
+
for (const requestDir of item.requestDirs) {
|
|
1576
|
+
await removeRequestDirIfIdentityMatches(requestDir, item.trace);
|
|
1577
|
+
}
|
|
1578
|
+
const locatorPaths = new Set(sessionRequestDirs(this.#rootDir, sessionId).map(requestsDir => (
|
|
1579
|
+
join(requestsDir, '..', 'turns', turnLocatorName(item.trace.requestId))
|
|
1580
|
+
)));
|
|
1581
|
+
for (const locatorPath of locatorPaths) {
|
|
1582
|
+
const locator = await readJson(locatorPath);
|
|
1583
|
+
if (locator?.sessionId === sessionId
|
|
1584
|
+
&& locator.requestId === item.trace.requestId
|
|
1585
|
+
&& locator.requestKey === item.trace.requestKey) {
|
|
1586
|
+
try { await fsp.rm(locatorPath, { force: true }); }
|
|
1587
|
+
catch { /* ignore */ }
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1302
1590
|
}
|
|
1303
1591
|
}
|
|
1304
1592
|
|
|
@@ -1331,6 +1619,7 @@ export class NullTrace {
|
|
|
1331
1619
|
async purge() {}
|
|
1332
1620
|
async close() {}
|
|
1333
1621
|
async flush() {}
|
|
1622
|
+
async fetchTurnDebug() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
1334
1623
|
async fetchRecentDebugHistory() { return { loops: [], turns: [], dreamEvents: [] }; }
|
|
1335
1624
|
}
|
|
1336
1625
|
|