@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.
@@ -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. Each request owns one
5
- * compact JSON file under the session's debug folder:
6
- * <yeaftDir>/sessions/<sessionId>/debug/requests/<requestKey>/trace.json
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
- * The file keeps one base request snapshot plus per-loop deltas. This avoids
9
- * 100-200 tiny loop files and avoids repeating the whole cumulative message
10
- * array for every loop. Debug history remains best-effort: failures are logged
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 = 2;
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 TRACE_FLUSH_INTERVAL_MS = 5_000;
35
- const TRACE_FLUSH_DIRTY_LOOPS = 10;
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(join(sessionRequestsDir(rootDir, sessionId), safeDirComponent(requestKey, 'request')));
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 collectTraceFiles(rootDir, sessionId = null) {
599
- const files = [];
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 (!entry.isDirectory()) continue;
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
- await addFromRequestsDir(sessionRequestsDir(rootDir, sessionId));
612
- return files;
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 files;
734
+ return dirs;
621
735
  }
622
736
 
623
737
  async function readTraceSummaries(rootDir, sessionId = null) {
624
738
  const traces = [];
625
- for (const file of await collectTraceFiles(rootDir, sessionId)) {
626
- const trace = await readJson(file);
739
+ for (const requestDir of await collectRequestDirs(rootDir, sessionId)) {
740
+ const trace = await readRequestDir(requestDir);
627
741
  if (!trace || !trace.requestId) continue;
628
- traces.push({ trace, file, openedAt: Number(trace.openedAt || 0) });
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 {Map<string, { trace: object, dirtyLoops: number, firstDirtyAt: number }>} */
659
- #pendingWrites = new Map();
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
- #flushTimer = null;
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.#markDirty(trace, { dirtyLoops: 1, force: !trace.active });
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
- trace.tools.push({
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.updatedAt = Date.now();
803
- this.#markDirty(trace, { dirtyLoops: 0, force: !trace.active });
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
- // #drainWrites() flushes pending writes itself; no separate #flushPending().
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
- #serializableTrace(trace) {
1116
- const toWrite = { ...trace };
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 (force || item.dirtyLoops >= TRACE_FLUSH_DIRTY_LOOPS) {
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.#scheduleFlushTimer(now);
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
- }, dueIn);
1147
- if (typeof this.#flushTimer.unref === 'function') this.#flushTimer.unref();
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
- }, TRACE_FLUSH_INTERVAL_MS);
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.#flushTimer) {
1169
- clearTimeout(this.#flushTimer);
1170
- this.#flushTimer = null;
1371
+ if (this.#appendTimer) {
1372
+ clearTimeout(this.#appendTimer);
1373
+ this.#appendTimer = null;
1171
1374
  }
1172
- const entries = Array.from(this.#pendingWrites.values());
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
- for (const { requestKey, file, text } of jobs) {
1189
- // Skip if an earlier chained prune already evicted this request.
1190
- if (!this.#requestCache.has(requestKey)) continue;
1191
- try { await atomicWriteText(file, text); }
1192
- catch (err) { console.warn('[Yeaft] debug trace write failed:', err?.message || err); }
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
- // Cache-only enumeration; rm the pruned request dirs async. No disk scan.
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 sid of sessions) await this.#pruneSession(sid, keep);
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 traces = this.#traceSummaries(sessionId);
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
- try { await fsp.rm(dirname(item.file), { recursive: true, force: true }); }
1301
- catch { /* ignore */ }
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