@yeaft/webchat-agent 0.1.653 → 0.1.655
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/connection/message-router.js +4 -12
- package/package.json +1 -1
- package/unify/engine.js +8 -29
- package/unify/session.js +11 -80
- package/unify/tools/index.js +3 -17
- package/unify/web-bridge.js +15 -93
- package/unify/input-queue/store.js +0 -322
- package/unify/pipeline/dispatcher.js +0 -196
- package/unify/threads/engine-instance.js +0 -312
- package/unify/threads/engine-registry.js +0 -260
- package/unify/threads/store.js +0 -886
- package/unify/tools/thread-tools.js +0 -289
package/unify/threads/store.js
DELETED
|
@@ -1,886 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* store.js — File-backed ThreadStore for Yeaft Unify (task-307a).
|
|
3
|
-
*
|
|
4
|
-
* Promotes the Phase-1 in-memory stub to persist threads under
|
|
5
|
-
* `~/.yeaft/threads/` so conversation structure survives agent restarts.
|
|
6
|
-
* API is backward compatible with the task-299 canonical surface — callers
|
|
7
|
-
* that used the in-memory version keep working without change, but a new
|
|
8
|
-
* optional `yeaftDir` argument (to the constructor / `initThreadStore`)
|
|
9
|
-
* switches persistence on.
|
|
10
|
-
*
|
|
11
|
-
* On-disk layout:
|
|
12
|
-
* ~/.yeaft/threads/
|
|
13
|
-
* index.md — Auto-generated overview (current thread id
|
|
14
|
-
* + attachments table + thread summary list).
|
|
15
|
-
* {threadId}.md — One markdown file per thread. YAML
|
|
16
|
-
* frontmatter holds every cached field, the
|
|
17
|
-
* body is the short preview.
|
|
18
|
-
*
|
|
19
|
-
* Write semantics:
|
|
20
|
-
* - Every mutation schedules a debounced flush (8 ms) of the set of dirty
|
|
21
|
-
* thread files and, when something changed, the index. A synchronous
|
|
22
|
-
* `flush()` is exposed for tests and graceful shutdown.
|
|
23
|
-
* - On construction we load any existing `{id}.md` files and rebuild the
|
|
24
|
-
* in-memory map + attachments, so round-trips are a simple "close /
|
|
25
|
-
* reopen".
|
|
26
|
-
* - Read-only mode (e.g. when `~/.yeaft/` is not writable) silently skips
|
|
27
|
-
* all filesystem writes — in-memory behaviour is preserved.
|
|
28
|
-
*
|
|
29
|
-
* Cached fields (task-299 contract, preserved verbatim):
|
|
30
|
-
* messageCount / lastMessageAt / lastActivityAt / archived / unread /
|
|
31
|
-
* preview. These all persist to the YAML frontmatter so ListThreads never
|
|
32
|
-
* needs to scan messages after a restart.
|
|
33
|
-
*
|
|
34
|
-
* A single "main" thread is always present after init — either loaded from
|
|
35
|
-
* disk or synthesised as a fresh record when the directory is empty.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
import {
|
|
39
|
-
existsSync,
|
|
40
|
-
mkdirSync,
|
|
41
|
-
readdirSync,
|
|
42
|
-
readFileSync,
|
|
43
|
-
unlinkSync,
|
|
44
|
-
writeFileSync,
|
|
45
|
-
} from 'fs';
|
|
46
|
-
import { join } from 'path';
|
|
47
|
-
import { randomUUID } from 'crypto';
|
|
48
|
-
|
|
49
|
-
/** Default / root thread id — every fresh ThreadStore has one. */
|
|
50
|
-
export const MAIN_THREAD_ID = 'main';
|
|
51
|
-
|
|
52
|
-
/** Valid thread status values. Mirrors design doc §5. */
|
|
53
|
-
export const THREAD_STATUSES = ['active', 'idle', 'archived'];
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* task-318: Coerce an idle-archive days input to a non-negative integer
|
|
57
|
-
* (0 disables the feature). Anything invalid falls through to 0 so a
|
|
58
|
-
* malformed config can never silently enable auto-archive.
|
|
59
|
-
*/
|
|
60
|
-
function normaliseIdleDays(n) {
|
|
61
|
-
if (n === null || n === undefined) return 0;
|
|
62
|
-
const v = Number(n);
|
|
63
|
-
if (!Number.isFinite(v) || v <= 0) return 0;
|
|
64
|
-
return Math.floor(v);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Debounce window for grouped disk writes. Kept short so tests don't hang. */
|
|
68
|
-
const FLUSH_DEBOUNCE_MS = 8;
|
|
69
|
-
|
|
70
|
-
// ─── YAML (de)serialisation ──────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Serialise a thread record to Markdown with YAML frontmatter.
|
|
74
|
-
*
|
|
75
|
-
* Only scalar / boolean / number / null frontmatter values are emitted.
|
|
76
|
-
* Strings that span multiple lines or contain leading whitespace get folded
|
|
77
|
-
* into a single-line value (threads' free-text goes in the body).
|
|
78
|
-
*
|
|
79
|
-
* @param {object} t
|
|
80
|
-
* @returns {string}
|
|
81
|
-
*/
|
|
82
|
-
function serializeThread(t) {
|
|
83
|
-
const forkedFrom = serializeForkedFrom(t.forkedFrom);
|
|
84
|
-
const fm = [
|
|
85
|
-
'---',
|
|
86
|
-
`id: ${t.id}`,
|
|
87
|
-
`name: ${escapeScalar(t.name)}`,
|
|
88
|
-
`goal: ${escapeScalar(t.goal || '')}`,
|
|
89
|
-
`parentThreadId: ${t.parentThreadId == null ? 'null' : t.parentThreadId}`,
|
|
90
|
-
`status: ${t.status}`,
|
|
91
|
-
`archived: ${t.archived ? 'true' : 'false'}`,
|
|
92
|
-
`mergedInto: ${t.mergedInto == null ? 'null' : t.mergedInto}`,
|
|
93
|
-
`forkedFrom: ${forkedFrom}`,
|
|
94
|
-
`messageCount: ${t.messageCount | 0}`,
|
|
95
|
-
`lastMessageAt: ${t.lastMessageAt == null ? 'null' : t.lastMessageAt}`,
|
|
96
|
-
`lastActivityAt: ${t.lastActivityAt == null ? 'null' : t.lastActivityAt}`,
|
|
97
|
-
`unread: ${t.unread | 0}`,
|
|
98
|
-
`createdAt: ${t.createdAt}`,
|
|
99
|
-
`updatedAt: ${t.updatedAt}`,
|
|
100
|
-
'---',
|
|
101
|
-
'',
|
|
102
|
-
];
|
|
103
|
-
// Body is the preview (wrapped so the file remains human-readable).
|
|
104
|
-
if (t.preview) fm.push(t.preview);
|
|
105
|
-
return fm.join('\n') + '\n';
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Serialise `forkedFrom` as a single-line scalar so the YAML stays flat.
|
|
110
|
-
* Shape: `{threadId}|{messageId}|{timestamp}`. Null becomes literal `null`.
|
|
111
|
-
*/
|
|
112
|
-
function serializeForkedFrom(ff) {
|
|
113
|
-
if (!ff || typeof ff !== 'object') return 'null';
|
|
114
|
-
if (!ff.threadId || !ff.messageId) return 'null';
|
|
115
|
-
const ts = Number.isFinite(ff.timestamp) ? ff.timestamp : 0;
|
|
116
|
-
return `${ff.threadId}|${ff.messageId}|${ts}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function parseForkedFrom(raw) {
|
|
120
|
-
if (!raw || raw === 'null') return null;
|
|
121
|
-
const parts = String(raw).split('|');
|
|
122
|
-
if (parts.length < 2) return null;
|
|
123
|
-
const [threadId, messageId, tsStr] = parts;
|
|
124
|
-
if (!threadId || !messageId) return null;
|
|
125
|
-
const ts = parseInt(tsStr || '0', 10);
|
|
126
|
-
return {
|
|
127
|
-
threadId,
|
|
128
|
-
messageId,
|
|
129
|
-
timestamp: Number.isFinite(ts) ? ts : 0,
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function escapeScalar(v) {
|
|
134
|
-
if (v == null) return '';
|
|
135
|
-
// Keep on one physical line; any embedded newline becomes a space so YAML
|
|
136
|
-
// stays flat.
|
|
137
|
-
return String(v).replace(/\s+/g, ' ').trim();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Parse the markdown-with-frontmatter produced by `serializeThread`. Returns
|
|
142
|
-
* null when the file is malformed; callers should skip it silently so one
|
|
143
|
-
* corrupt thread file never blocks recovery of the rest.
|
|
144
|
-
*
|
|
145
|
-
* @param {string} raw
|
|
146
|
-
* @returns {object|null}
|
|
147
|
-
*/
|
|
148
|
-
function parseThread(raw) {
|
|
149
|
-
if (!raw || !raw.startsWith('---')) return null;
|
|
150
|
-
const end = raw.indexOf('\n---', 3);
|
|
151
|
-
if (end === -1) return null;
|
|
152
|
-
const fm = raw.slice(3, end).trim();
|
|
153
|
-
const body = raw.slice(end + 4).replace(/^\n/, '').trimEnd();
|
|
154
|
-
const record = {};
|
|
155
|
-
for (const line of fm.split('\n')) {
|
|
156
|
-
const idx = line.indexOf(':');
|
|
157
|
-
if (idx === -1) continue;
|
|
158
|
-
const key = line.slice(0, idx).trim();
|
|
159
|
-
const rawVal = line.slice(idx + 1).trim();
|
|
160
|
-
if (!key) continue;
|
|
161
|
-
if (rawVal === 'null' || rawVal === '') {
|
|
162
|
-
record[key] = null;
|
|
163
|
-
} else if (rawVal === 'true' || rawVal === 'false') {
|
|
164
|
-
record[key] = rawVal === 'true';
|
|
165
|
-
} else if (/^-?\d+$/.test(rawVal)) {
|
|
166
|
-
record[key] = parseInt(rawVal, 10);
|
|
167
|
-
} else {
|
|
168
|
-
record[key] = rawVal;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (!record.id || !record.name) return null;
|
|
172
|
-
// Default to safe values if the file pre-dates a field.
|
|
173
|
-
if (!THREAD_STATUSES.includes(record.status)) record.status = 'active';
|
|
174
|
-
record.archived = record.status === 'archived';
|
|
175
|
-
if (!('mergedInto' in record)) record.mergedInto = null;
|
|
176
|
-
// forkedFrom is a packed scalar — decode back to {threadId, messageId, timestamp}.
|
|
177
|
-
record.forkedFrom = parseForkedFrom(record.forkedFrom);
|
|
178
|
-
record.messageCount = Number.isFinite(record.messageCount) ? record.messageCount : 0;
|
|
179
|
-
record.unread = Number.isFinite(record.unread) ? record.unread : 0;
|
|
180
|
-
record.preview = body;
|
|
181
|
-
record.lastActivityAt = record.lastActivityAt ?? record.lastMessageAt ?? null;
|
|
182
|
-
return record;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Generate `index.md` — a human-readable roll-up of all threads in the
|
|
187
|
-
* store, the current thread marker, and attachments. Parsers should NOT
|
|
188
|
-
* depend on this file; it exists for human inspection and crash-triage.
|
|
189
|
-
*/
|
|
190
|
-
function generateIndex(threads, currentId, attachments) {
|
|
191
|
-
const now = new Date().toISOString();
|
|
192
|
-
const lines = [
|
|
193
|
-
'---',
|
|
194
|
-
`currentId: ${currentId}`,
|
|
195
|
-
`totalThreads: ${threads.size}`,
|
|
196
|
-
`lastUpdated: ${now}`,
|
|
197
|
-
'---',
|
|
198
|
-
'# Thread Index',
|
|
199
|
-
'',
|
|
200
|
-
'| ID | Name | Status | Messages | Last Activity |',
|
|
201
|
-
'|----|------|--------|----------|---------------|',
|
|
202
|
-
];
|
|
203
|
-
for (const t of threads.values()) {
|
|
204
|
-
const stamp = t.lastActivityAt
|
|
205
|
-
? new Date(t.lastActivityAt).toISOString().slice(0, 19).replace('T', ' ')
|
|
206
|
-
: '-';
|
|
207
|
-
lines.push(`| ${t.id} | ${t.name} | ${t.status} | ${t.messageCount} | ${stamp} |`);
|
|
208
|
-
}
|
|
209
|
-
if (attachments.size > 0) {
|
|
210
|
-
lines.push('');
|
|
211
|
-
lines.push('## Attachments');
|
|
212
|
-
lines.push('');
|
|
213
|
-
lines.push('| Thread | Feature |');
|
|
214
|
-
lines.push('|--------|---------|');
|
|
215
|
-
for (const [threadId, featureId] of attachments.entries()) {
|
|
216
|
-
lines.push(`| ${threadId} | ${featureId} |`);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
return lines.join('\n') + '\n';
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Serialise the attachments map to a stable JSON payload (array form so key
|
|
224
|
-
* order is preserved on reload). Stored separately from `index.md` so the
|
|
225
|
-
* human-readable index stays cosmetic.
|
|
226
|
-
*/
|
|
227
|
-
function serializeAttachments(attachments) {
|
|
228
|
-
return JSON.stringify(
|
|
229
|
-
[...attachments.entries()].map(([threadId, featureId]) => ({ threadId, featureId })),
|
|
230
|
-
null,
|
|
231
|
-
2,
|
|
232
|
-
) + '\n';
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function parseAttachments(raw) {
|
|
236
|
-
try {
|
|
237
|
-
const arr = JSON.parse(raw);
|
|
238
|
-
if (!Array.isArray(arr)) return [];
|
|
239
|
-
return arr.filter(
|
|
240
|
-
(e) => e && typeof e.threadId === 'string' && typeof e.featureId === 'string',
|
|
241
|
-
);
|
|
242
|
-
} catch {
|
|
243
|
-
return [];
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
// ─── ThreadStore class ───────────────────────────────────────────────────
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* @typedef {Object} Thread
|
|
251
|
-
* @property {string} id
|
|
252
|
-
* @property {string} name
|
|
253
|
-
* @property {string} [goal]
|
|
254
|
-
* @property {string|null} parentThreadId
|
|
255
|
-
* @property {'active'|'idle'|'archived'} status
|
|
256
|
-
* @property {number} messageCount
|
|
257
|
-
* @property {number|null} lastMessageAt
|
|
258
|
-
* @property {number|null} lastActivityAt
|
|
259
|
-
* @property {boolean} archived
|
|
260
|
-
* @property {number} unread
|
|
261
|
-
* @property {string} preview
|
|
262
|
-
* @property {number} createdAt
|
|
263
|
-
* @property {number} updatedAt
|
|
264
|
-
*/
|
|
265
|
-
|
|
266
|
-
export class ThreadStore {
|
|
267
|
-
/** @type {Map<string, Thread>} */
|
|
268
|
-
#threads;
|
|
269
|
-
/** @type {string} */
|
|
270
|
-
#currentId;
|
|
271
|
-
/** @type {Map<string, string>} threadId → featureId */
|
|
272
|
-
#attachments;
|
|
273
|
-
|
|
274
|
-
/** @type {string|null} */
|
|
275
|
-
#dir;
|
|
276
|
-
/** @type {string|null} */
|
|
277
|
-
#indexPath;
|
|
278
|
-
/** @type {string|null} */
|
|
279
|
-
#attachmentsPath;
|
|
280
|
-
/** @type {boolean} */
|
|
281
|
-
#readOnly;
|
|
282
|
-
/** @type {Set<string>} dirty thread ids pending flush */
|
|
283
|
-
#dirtyThreads;
|
|
284
|
-
/** @type {boolean} */
|
|
285
|
-
#dirtyIndex;
|
|
286
|
-
/** @type {boolean} */
|
|
287
|
-
#dirtyAttachments;
|
|
288
|
-
/** @type {any} NodeJS.Timeout */
|
|
289
|
-
#flushTimer;
|
|
290
|
-
/** @type {number} task-318: days of inactivity before auto-archive (0 = disabled) */
|
|
291
|
-
#idleArchiveDays;
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* @param {string} [yeaftDir] — Base ~/.yeaft directory. Omit for in-memory mode.
|
|
295
|
-
* @param {{ readOnly?: boolean, idleArchiveDays?: number }} [opts]
|
|
296
|
-
*/
|
|
297
|
-
constructor(yeaftDir, opts = {}) {
|
|
298
|
-
this.#threads = new Map();
|
|
299
|
-
this.#attachments = new Map();
|
|
300
|
-
this.#currentId = MAIN_THREAD_ID;
|
|
301
|
-
this.#dirtyThreads = new Set();
|
|
302
|
-
this.#dirtyIndex = false;
|
|
303
|
-
this.#dirtyAttachments = false;
|
|
304
|
-
this.#flushTimer = null;
|
|
305
|
-
|
|
306
|
-
this.#readOnly = !!opts.readOnly;
|
|
307
|
-
// task-318: idle-archive threshold (days). Consumed by the archive
|
|
308
|
-
// pass in task-317; here we just accept + expose the knob so the
|
|
309
|
-
// config plumbing is testable end-to-end today. A value ≤ 0 means
|
|
310
|
-
// "no auto-archive" (feature disabled).
|
|
311
|
-
this.#idleArchiveDays = normaliseIdleDays(opts.idleArchiveDays);
|
|
312
|
-
if (yeaftDir) {
|
|
313
|
-
this.#dir = join(yeaftDir, 'threads');
|
|
314
|
-
this.#indexPath = join(this.#dir, 'index.md');
|
|
315
|
-
this.#attachmentsPath = join(this.#dir, 'attachments.json');
|
|
316
|
-
if (!this.#readOnly) {
|
|
317
|
-
try {
|
|
318
|
-
if (!existsSync(this.#dir)) mkdirSync(this.#dir, { recursive: true });
|
|
319
|
-
} catch {
|
|
320
|
-
this.#readOnly = true;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
this.#loadAll();
|
|
324
|
-
} else {
|
|
325
|
-
this.#dir = null;
|
|
326
|
-
this.#indexPath = null;
|
|
327
|
-
this.#attachmentsPath = null;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Ensure the main thread is always present.
|
|
331
|
-
if (!this.#threads.has(MAIN_THREAD_ID)) {
|
|
332
|
-
const now = Date.now();
|
|
333
|
-
this.#threads.set(
|
|
334
|
-
MAIN_THREAD_ID,
|
|
335
|
-
this.#newThreadRecord({
|
|
336
|
-
id: MAIN_THREAD_ID,
|
|
337
|
-
name: 'main',
|
|
338
|
-
goal: '',
|
|
339
|
-
parentThreadId: null,
|
|
340
|
-
createdAt: now,
|
|
341
|
-
updatedAt: now,
|
|
342
|
-
}),
|
|
343
|
-
);
|
|
344
|
-
this.#markDirty(MAIN_THREAD_ID);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// If the on-disk currentId is unknown, fall back to main.
|
|
348
|
-
if (!this.#threads.has(this.#currentId)) {
|
|
349
|
-
this.#currentId = MAIN_THREAD_ID;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** Build a thread record with default cached fields. */
|
|
354
|
-
#newThreadRecord(base) {
|
|
355
|
-
return {
|
|
356
|
-
status: 'active',
|
|
357
|
-
messageCount: 0,
|
|
358
|
-
lastMessageAt: null,
|
|
359
|
-
lastActivityAt: null,
|
|
360
|
-
archived: false,
|
|
361
|
-
mergedInto: null,
|
|
362
|
-
forkedFrom: null,
|
|
363
|
-
unread: 0,
|
|
364
|
-
preview: '',
|
|
365
|
-
...base,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// ─── load / persist ────────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
/** Load all thread files + attachments from disk into memory. */
|
|
372
|
-
#loadAll() {
|
|
373
|
-
if (!this.#dir || !existsSync(this.#dir)) return;
|
|
374
|
-
let entries;
|
|
375
|
-
try {
|
|
376
|
-
entries = readdirSync(this.#dir, { withFileTypes: true });
|
|
377
|
-
} catch {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
for (const entry of entries) {
|
|
381
|
-
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
382
|
-
if (entry.name === 'index.md') continue;
|
|
383
|
-
const id = entry.name.slice(0, -3);
|
|
384
|
-
try {
|
|
385
|
-
const raw = readFileSync(join(this.#dir, entry.name), 'utf8');
|
|
386
|
-
const parsed = parseThread(raw);
|
|
387
|
-
if (parsed && parsed.id === id) {
|
|
388
|
-
this.#threads.set(id, parsed);
|
|
389
|
-
}
|
|
390
|
-
} catch {
|
|
391
|
-
// Skip corrupt files silently.
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
// Attachments side-car.
|
|
395
|
-
try {
|
|
396
|
-
if (this.#attachmentsPath && existsSync(this.#attachmentsPath)) {
|
|
397
|
-
const raw = readFileSync(this.#attachmentsPath, 'utf8');
|
|
398
|
-
for (const { threadId, featureId } of parseAttachments(raw)) {
|
|
399
|
-
if (this.#threads.has(threadId)) {
|
|
400
|
-
this.#attachments.set(threadId, featureId);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
} catch {
|
|
405
|
-
// Skip silently.
|
|
406
|
-
}
|
|
407
|
-
// Try to recover currentId from index.md frontmatter.
|
|
408
|
-
try {
|
|
409
|
-
if (this.#indexPath && existsSync(this.#indexPath)) {
|
|
410
|
-
const raw = readFileSync(this.#indexPath, 'utf8');
|
|
411
|
-
const m = raw.match(/^currentId:\s*(\S+)/m);
|
|
412
|
-
if (m && this.#threads.has(m[1])) {
|
|
413
|
-
this.#currentId = m[1];
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
} catch {
|
|
417
|
-
// Ignore — fall back to main on miss.
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
#markDirty(threadId) {
|
|
422
|
-
if (!this.#dir || this.#readOnly) return;
|
|
423
|
-
this.#dirtyThreads.add(threadId);
|
|
424
|
-
this.#dirtyIndex = true;
|
|
425
|
-
this.#scheduleFlush();
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
#markAttachmentsDirty() {
|
|
429
|
-
if (!this.#dir || this.#readOnly) return;
|
|
430
|
-
this.#dirtyAttachments = true;
|
|
431
|
-
this.#dirtyIndex = true;
|
|
432
|
-
this.#scheduleFlush();
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
#scheduleFlush() {
|
|
436
|
-
if (this.#flushTimer || typeof setTimeout !== 'function') return;
|
|
437
|
-
this.#flushTimer = setTimeout(() => {
|
|
438
|
-
this.#flushTimer = null;
|
|
439
|
-
this.flush();
|
|
440
|
-
}, FLUSH_DEBOUNCE_MS);
|
|
441
|
-
// Do not hold the process open for a pending flush — if the Node loop
|
|
442
|
-
// has nothing else to do, the store still makes it through shutdown
|
|
443
|
-
// via explicit flush() or process exit handlers.
|
|
444
|
-
if (this.#flushTimer && typeof this.#flushTimer.unref === 'function') {
|
|
445
|
-
this.#flushTimer.unref();
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
/**
|
|
450
|
-
* Write any pending dirty state to disk immediately. Safe to call on an
|
|
451
|
-
* in-memory or read-only store (it becomes a no-op). Returns the number of
|
|
452
|
-
* files written.
|
|
453
|
-
*/
|
|
454
|
-
flush() {
|
|
455
|
-
if (!this.#dir || this.#readOnly) {
|
|
456
|
-
this.#dirtyThreads.clear();
|
|
457
|
-
this.#dirtyIndex = false;
|
|
458
|
-
this.#dirtyAttachments = false;
|
|
459
|
-
return 0;
|
|
460
|
-
}
|
|
461
|
-
let written = 0;
|
|
462
|
-
for (const id of this.#dirtyThreads) {
|
|
463
|
-
const t = this.#threads.get(id);
|
|
464
|
-
if (!t) {
|
|
465
|
-
// Deleted thread → remove the file if present.
|
|
466
|
-
try {
|
|
467
|
-
const p = join(this.#dir, `${id}.md`);
|
|
468
|
-
if (existsSync(p)) {
|
|
469
|
-
unlinkSync(p);
|
|
470
|
-
written += 1;
|
|
471
|
-
}
|
|
472
|
-
} catch {
|
|
473
|
-
// ignore
|
|
474
|
-
}
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
try {
|
|
478
|
-
writeFileSync(join(this.#dir, `${id}.md`), serializeThread(t), 'utf8');
|
|
479
|
-
written += 1;
|
|
480
|
-
} catch {
|
|
481
|
-
// Best-effort
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
this.#dirtyThreads.clear();
|
|
485
|
-
if (this.#dirtyAttachments) {
|
|
486
|
-
try {
|
|
487
|
-
writeFileSync(this.#attachmentsPath, serializeAttachments(this.#attachments), 'utf8');
|
|
488
|
-
} catch {
|
|
489
|
-
// ignore
|
|
490
|
-
}
|
|
491
|
-
this.#dirtyAttachments = false;
|
|
492
|
-
}
|
|
493
|
-
if (this.#dirtyIndex) {
|
|
494
|
-
try {
|
|
495
|
-
writeFileSync(this.#indexPath, generateIndex(this.#threads, this.#currentId, this.#attachments), 'utf8');
|
|
496
|
-
} catch {
|
|
497
|
-
// ignore
|
|
498
|
-
}
|
|
499
|
-
this.#dirtyIndex = false;
|
|
500
|
-
}
|
|
501
|
-
return written;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// ─── Query API (unchanged) ─────────────────────────────────────────
|
|
505
|
-
|
|
506
|
-
get currentId() { return this.#currentId; }
|
|
507
|
-
get size() { return this.#threads.size; }
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* task-318: idle-archive threshold (days). 0 disables auto-archive.
|
|
511
|
-
* The actual archive pass is owned by task-317 — this accessor is the
|
|
512
|
-
* contract between config and that future consumer, plus a write path
|
|
513
|
-
* so the Settings UI takes effect without restarting the session.
|
|
514
|
-
* @returns {number}
|
|
515
|
-
*/
|
|
516
|
-
get idleArchiveDays() { return this.#idleArchiveDays; }
|
|
517
|
-
setIdleArchiveDays(days) {
|
|
518
|
-
this.#idleArchiveDays = normaliseIdleDays(days);
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* task-317: auto-archive pass. Scans every non-archived thread (except
|
|
523
|
-
* `main`, which is never auto-archived) and archives those whose
|
|
524
|
-
* `lastMessageAt` (fallback: `lastActivityAt`, fallback: `createdAt`)
|
|
525
|
-
* is older than `now - idleArchiveDays * 86400000 ms`.
|
|
526
|
-
*
|
|
527
|
-
* Returns the list of newly-archived thread ids so callers can decide
|
|
528
|
-
* whether to broadcast a UI update (no archived → no broadcast).
|
|
529
|
-
*
|
|
530
|
-
* Constraints:
|
|
531
|
-
* - `idleArchiveDays === 0` disables the feature entirely (returns []).
|
|
532
|
-
* - The main thread is NEVER archived regardless of its activity.
|
|
533
|
-
* - Already-archived threads are skipped (idempotent).
|
|
534
|
-
* - Threads with no recorded activity fall back to `createdAt`; a
|
|
535
|
-
* thread created 100 days ago with zero messages IS archived when
|
|
536
|
-
* idleArchiveDays ≤ 100 — silent threads aren't a special case.
|
|
537
|
-
*
|
|
538
|
-
* @param {number} [now] — override the clock for tests
|
|
539
|
-
* @returns {{ archived: string[] }}
|
|
540
|
-
*/
|
|
541
|
-
runArchivePass(now = Date.now()) {
|
|
542
|
-
if (this.#idleArchiveDays <= 0) return { archived: [] };
|
|
543
|
-
const cutoff = now - this.#idleArchiveDays * 86400000;
|
|
544
|
-
const archived = [];
|
|
545
|
-
for (const t of this.#threads.values()) {
|
|
546
|
-
if (t.id === MAIN_THREAD_ID) continue;
|
|
547
|
-
if (t.archived || t.status === 'archived') continue;
|
|
548
|
-
const ref = t.lastMessageAt ?? t.lastActivityAt ?? t.createdAt ?? now;
|
|
549
|
-
if (ref > cutoff) continue;
|
|
550
|
-
t.status = 'archived';
|
|
551
|
-
t.archived = true;
|
|
552
|
-
t.updatedAt = now;
|
|
553
|
-
this.#markDirty(t.id);
|
|
554
|
-
archived.push(t.id);
|
|
555
|
-
}
|
|
556
|
-
return { archived };
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
get(id) { return this.#threads.get(id) || null; }
|
|
560
|
-
list() { return [...this.#threads.values()]; }
|
|
561
|
-
has(id) { return this.#threads.has(id); }
|
|
562
|
-
|
|
563
|
-
// ─── Mutation API (all calls schedule a debounced flush) ───────────
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Create a new thread.
|
|
567
|
-
* @param {{ name: string, goal?: string, parentThreadId?: string }} spec
|
|
568
|
-
* @returns {Thread}
|
|
569
|
-
*/
|
|
570
|
-
create({ name, goal = '', parentThreadId = null } = {}) {
|
|
571
|
-
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
572
|
-
throw new Error('thread name is required');
|
|
573
|
-
}
|
|
574
|
-
if (parentThreadId && !this.#threads.has(parentThreadId)) {
|
|
575
|
-
throw new Error(`parent thread not found: ${parentThreadId}`);
|
|
576
|
-
}
|
|
577
|
-
const id = `thr-${randomUUID().slice(0, 8)}`;
|
|
578
|
-
const now = Date.now();
|
|
579
|
-
const thread = this.#newThreadRecord({
|
|
580
|
-
id,
|
|
581
|
-
name: name.trim(),
|
|
582
|
-
goal: goal || '',
|
|
583
|
-
parentThreadId: parentThreadId || null,
|
|
584
|
-
createdAt: now,
|
|
585
|
-
updatedAt: now,
|
|
586
|
-
});
|
|
587
|
-
this.#threads.set(id, thread);
|
|
588
|
-
this.#markDirty(id);
|
|
589
|
-
return thread;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Set the current thread marker. Throws if unknown. */
|
|
593
|
-
switch(id) {
|
|
594
|
-
if (!this.#threads.has(id)) {
|
|
595
|
-
throw new Error(`thread not found: ${id}`);
|
|
596
|
-
}
|
|
597
|
-
this.#currentId = id;
|
|
598
|
-
const t = this.#threads.get(id);
|
|
599
|
-
t.updatedAt = Date.now();
|
|
600
|
-
this.#markDirty(id);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Record that a message has been persisted on a thread. Increments the
|
|
605
|
-
* cached messageCount and updates lastMessageAt. Safe to call repeatedly;
|
|
606
|
-
* unknown threadIds are silently ignored.
|
|
607
|
-
*/
|
|
608
|
-
noteMessage(threadId, at = Date.now(), opts = {}) {
|
|
609
|
-
const t = this.#threads.get(threadId);
|
|
610
|
-
if (!t) return;
|
|
611
|
-
t.messageCount += 1;
|
|
612
|
-
t.lastMessageAt = at;
|
|
613
|
-
t.lastActivityAt = at;
|
|
614
|
-
t.updatedAt = at;
|
|
615
|
-
if (opts.countsAsUnread !== false) {
|
|
616
|
-
t.unread += 1;
|
|
617
|
-
}
|
|
618
|
-
if (typeof opts.preview === 'string' && opts.preview.length > 0) {
|
|
619
|
-
const p = opts.preview.replace(/\s+/g, ' ').trim();
|
|
620
|
-
t.preview = p.length > 160 ? p.slice(0, 157) + '...' : p;
|
|
621
|
-
}
|
|
622
|
-
if (t.status === 'archived') {
|
|
623
|
-
t.status = 'active';
|
|
624
|
-
t.archived = false;
|
|
625
|
-
}
|
|
626
|
-
this.#markDirty(threadId);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
/** Mark a thread as read — resets unread counter to 0. */
|
|
630
|
-
markRead(threadId) {
|
|
631
|
-
const t = this.#threads.get(threadId);
|
|
632
|
-
if (!t) return;
|
|
633
|
-
if (t.unread === 0) return;
|
|
634
|
-
t.unread = 0;
|
|
635
|
-
this.#markDirty(threadId);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
archive(id) {
|
|
639
|
-
const t = this.#threads.get(id);
|
|
640
|
-
if (!t) throw new Error(`thread not found: ${id}`);
|
|
641
|
-
if (id === MAIN_THREAD_ID) throw new Error('cannot archive main thread');
|
|
642
|
-
t.status = 'archived';
|
|
643
|
-
t.archived = true;
|
|
644
|
-
t.updatedAt = Date.now();
|
|
645
|
-
this.#markDirty(id);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Merge the source thread into the target (task-313). The source is
|
|
650
|
-
* marked archived and gets `mergedInto: targetId`; target's cached
|
|
651
|
-
* counters pick up the source's message count and activity. Callers
|
|
652
|
-
* are still expected to reassign the actual messages on disk via
|
|
653
|
-
* `ConversationStore.reassignThread(sourceId, targetId)`.
|
|
654
|
-
*
|
|
655
|
-
* Constraints:
|
|
656
|
-
* - source !== target
|
|
657
|
-
* - both threads must exist
|
|
658
|
-
* - source cannot be the main thread (main cannot be archived)
|
|
659
|
-
* - source cannot already have been merged elsewhere (idempotency)
|
|
660
|
-
*
|
|
661
|
-
* @param {string} sourceId
|
|
662
|
-
* @param {string} targetId
|
|
663
|
-
* @returns {{ source: Thread, target: Thread }}
|
|
664
|
-
*/
|
|
665
|
-
mergeThread(sourceId, targetId) {
|
|
666
|
-
if (!sourceId || !targetId) {
|
|
667
|
-
throw new Error('mergeThread: sourceId and targetId required');
|
|
668
|
-
}
|
|
669
|
-
if (sourceId === targetId) {
|
|
670
|
-
throw new Error('mergeThread: cannot merge a thread into itself');
|
|
671
|
-
}
|
|
672
|
-
if (sourceId === MAIN_THREAD_ID) {
|
|
673
|
-
throw new Error('mergeThread: cannot merge the main thread into another');
|
|
674
|
-
}
|
|
675
|
-
const source = this.#threads.get(sourceId);
|
|
676
|
-
if (!source) throw new Error(`thread not found: ${sourceId}`);
|
|
677
|
-
const target = this.#threads.get(targetId);
|
|
678
|
-
if (!target) throw new Error(`thread not found: ${targetId}`);
|
|
679
|
-
if (source.mergedInto) {
|
|
680
|
-
throw new Error(`thread ${sourceId} already merged into ${source.mergedInto}`);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
const now = Date.now();
|
|
684
|
-
|
|
685
|
-
// Accumulate counters onto target.
|
|
686
|
-
target.messageCount += source.messageCount;
|
|
687
|
-
if (source.lastMessageAt && (!target.lastMessageAt || source.lastMessageAt > target.lastMessageAt)) {
|
|
688
|
-
target.lastMessageAt = source.lastMessageAt;
|
|
689
|
-
}
|
|
690
|
-
if (source.lastActivityAt && (!target.lastActivityAt || source.lastActivityAt > target.lastActivityAt)) {
|
|
691
|
-
target.lastActivityAt = source.lastActivityAt;
|
|
692
|
-
}
|
|
693
|
-
target.updatedAt = now;
|
|
694
|
-
// Revive target if it was archived — a merge is an activity signal.
|
|
695
|
-
if (target.status === 'archived') {
|
|
696
|
-
target.status = 'active';
|
|
697
|
-
target.archived = false;
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// Mark source as archived + pointer to target.
|
|
701
|
-
source.status = 'archived';
|
|
702
|
-
source.archived = true;
|
|
703
|
-
source.mergedInto = targetId;
|
|
704
|
-
source.updatedAt = now;
|
|
705
|
-
|
|
706
|
-
// If source was current, move the pointer to target.
|
|
707
|
-
if (this.#currentId === sourceId) {
|
|
708
|
-
this.#currentId = targetId;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
// Drop any feature attachment on source (it now belongs to target).
|
|
712
|
-
if (this.#attachments.has(sourceId)) {
|
|
713
|
-
const featureId = this.#attachments.get(sourceId);
|
|
714
|
-
this.#attachments.delete(sourceId);
|
|
715
|
-
// Preserve attachment on target if it had none; otherwise keep target's.
|
|
716
|
-
if (!this.#attachments.has(targetId)) {
|
|
717
|
-
this.#attachments.set(targetId, featureId);
|
|
718
|
-
}
|
|
719
|
-
this.#markAttachmentsDirty();
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
this.#markDirty(sourceId);
|
|
723
|
-
this.#markDirty(targetId);
|
|
724
|
-
return { source, target };
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Fork a new thread from an existing one at a specific message cursor.
|
|
729
|
-
* ThreadStore only creates the new thread record (with `forkedFrom`
|
|
730
|
-
* pointing at source + message + timestamp); the actual copying of
|
|
731
|
-
* messages up to `atMessageId` is done by ConversationStore.copyThreadUpTo
|
|
732
|
-
* — this keeps the two stores' responsibilities separate.
|
|
733
|
-
*
|
|
734
|
-
* Validation:
|
|
735
|
-
* - source must exist
|
|
736
|
-
* - source must not be archived (forking a dead thread is confusing)
|
|
737
|
-
* - atMessageId must be a non-empty string (actual existence check is
|
|
738
|
-
* the caller's responsibility, since ThreadStore doesn't own messages)
|
|
739
|
-
* - source may itself be a fork (chain is supported)
|
|
740
|
-
*
|
|
741
|
-
* @param {string} sourceId
|
|
742
|
-
* @param {string} atMessageId
|
|
743
|
-
* @param {{ name?: string, title?: string, timestamp?: number }} [opts]
|
|
744
|
-
* @returns {Thread} the newly created forked thread record
|
|
745
|
-
*/
|
|
746
|
-
forkThread(sourceId, atMessageId, opts = {}) {
|
|
747
|
-
if (!sourceId) throw new Error('forkThread: sourceId required');
|
|
748
|
-
if (!atMessageId || typeof atMessageId !== 'string') {
|
|
749
|
-
throw new Error('forkThread: atMessageId required');
|
|
750
|
-
}
|
|
751
|
-
const source = this.#threads.get(sourceId);
|
|
752
|
-
if (!source) throw new Error(`thread not found: ${sourceId}`);
|
|
753
|
-
if (source.archived || source.status === 'archived') {
|
|
754
|
-
throw new Error(`forkThread: cannot fork an archived thread (${sourceId})`);
|
|
755
|
-
}
|
|
756
|
-
const now = Date.now();
|
|
757
|
-
const id = `thr-${randomUUID().slice(0, 8)}`;
|
|
758
|
-
const defaultName = source.id === MAIN_THREAD_ID ? 'inbox-fork' : `${source.name}-fork`;
|
|
759
|
-
const thread = this.#newThreadRecord({
|
|
760
|
-
id,
|
|
761
|
-
name: (opts.name && opts.name.trim()) || defaultName,
|
|
762
|
-
goal: source.goal || '',
|
|
763
|
-
parentThreadId: sourceId,
|
|
764
|
-
createdAt: now,
|
|
765
|
-
updatedAt: now,
|
|
766
|
-
forkedFrom: {
|
|
767
|
-
threadId: sourceId,
|
|
768
|
-
messageId: atMessageId,
|
|
769
|
-
timestamp: Number.isFinite(opts.timestamp) ? opts.timestamp : now,
|
|
770
|
-
},
|
|
771
|
-
});
|
|
772
|
-
this.#threads.set(id, thread);
|
|
773
|
-
this.#markDirty(id);
|
|
774
|
-
return thread;
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
setStatus(id, status) {
|
|
778
|
-
if (!THREAD_STATUSES.includes(status)) {
|
|
779
|
-
throw new Error(`invalid status: ${status}`);
|
|
780
|
-
}
|
|
781
|
-
const t = this.#threads.get(id);
|
|
782
|
-
if (!t) throw new Error(`thread not found: ${id}`);
|
|
783
|
-
if (id === MAIN_THREAD_ID && status === 'archived') {
|
|
784
|
-
throw new Error('cannot archive main thread');
|
|
785
|
-
}
|
|
786
|
-
t.status = status;
|
|
787
|
-
t.archived = status === 'archived';
|
|
788
|
-
t.updatedAt = Date.now();
|
|
789
|
-
this.#markDirty(id);
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
/**
|
|
793
|
-
* Rebuild cached fields (messageCount/lastMessageAt) from a flat messages
|
|
794
|
-
* list. Used for crash recovery or as a sanity check in tests.
|
|
795
|
-
*/
|
|
796
|
-
rebuildFromMessages(messages) {
|
|
797
|
-
for (const t of this.#threads.values()) {
|
|
798
|
-
t.messageCount = 0;
|
|
799
|
-
t.lastMessageAt = null;
|
|
800
|
-
t.lastActivityAt = null;
|
|
801
|
-
this.#markDirty(t.id);
|
|
802
|
-
}
|
|
803
|
-
for (const m of messages || []) {
|
|
804
|
-
const tid = m.threadId || MAIN_THREAD_ID;
|
|
805
|
-
const t = this.#threads.get(tid);
|
|
806
|
-
if (!t) continue;
|
|
807
|
-
t.messageCount += 1;
|
|
808
|
-
const ts = typeof m.createdAt === 'number' ? m.createdAt : Date.now();
|
|
809
|
-
if (!t.lastMessageAt || ts > t.lastMessageAt) {
|
|
810
|
-
t.lastMessageAt = ts;
|
|
811
|
-
t.lastActivityAt = ts;
|
|
812
|
-
}
|
|
813
|
-
this.#markDirty(tid);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
attachFeature(threadId, featureId) {
|
|
818
|
-
if (!this.#threads.has(threadId)) {
|
|
819
|
-
throw new Error(`thread not found: ${threadId}`);
|
|
820
|
-
}
|
|
821
|
-
if (!featureId || typeof featureId !== 'string') {
|
|
822
|
-
throw new Error('featureId is required');
|
|
823
|
-
}
|
|
824
|
-
this.#attachments.set(threadId, featureId);
|
|
825
|
-
this.#markAttachmentsDirty();
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
attachedFeature(threadId) {
|
|
829
|
-
return this.#attachments.get(threadId) || null;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
listAttachments() {
|
|
833
|
-
return [...this.#attachments.entries()].map(([threadId, featureId]) => ({ threadId, featureId }));
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// ─── Singleton helpers ───────────────────────────────────────────────────
|
|
838
|
-
|
|
839
|
-
/** @type {ThreadStore|null} */
|
|
840
|
-
let threadStore = null;
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* Initialise the thread store. Safe to call multiple times — subsequent calls
|
|
844
|
-
* replace the store only if `force` is true (primarily for tests).
|
|
845
|
-
*
|
|
846
|
-
* Accepts either `initThreadStore()` (legacy, in-memory) or
|
|
847
|
-
* `initThreadStore(yeaftDir, opts)` (persistent). Legacy callers keep working.
|
|
848
|
-
*
|
|
849
|
-
* @param {string|{ force?: boolean }} [yeaftDirOrOpts]
|
|
850
|
-
* @param {{ force?: boolean, readOnly?: boolean }} [opts]
|
|
851
|
-
* @returns {ThreadStore}
|
|
852
|
-
*/
|
|
853
|
-
export function initThreadStore(yeaftDirOrOpts, opts = {}) {
|
|
854
|
-
let yeaftDir;
|
|
855
|
-
let mergedOpts;
|
|
856
|
-
if (typeof yeaftDirOrOpts === 'string') {
|
|
857
|
-
yeaftDir = yeaftDirOrOpts;
|
|
858
|
-
mergedOpts = opts || {};
|
|
859
|
-
} else {
|
|
860
|
-
yeaftDir = undefined;
|
|
861
|
-
mergedOpts = yeaftDirOrOpts || {};
|
|
862
|
-
}
|
|
863
|
-
if (!threadStore || mergedOpts.force) {
|
|
864
|
-
threadStore = new ThreadStore(yeaftDir, mergedOpts);
|
|
865
|
-
}
|
|
866
|
-
return threadStore;
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/** @returns {ThreadStore} */
|
|
870
|
-
export function getThreadStore() {
|
|
871
|
-
if (!threadStore) {
|
|
872
|
-
threadStore = new ThreadStore();
|
|
873
|
-
}
|
|
874
|
-
return threadStore;
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
/** Test-only reset helper. */
|
|
878
|
-
export function _resetThreadStoreForTests() {
|
|
879
|
-
if (threadStore && typeof threadStore.flush === 'function') {
|
|
880
|
-
try { threadStore.flush(); } catch { /* ignore */ }
|
|
881
|
-
}
|
|
882
|
-
threadStore = null;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// Exported for tests.
|
|
886
|
-
export { serializeThread as _serializeThread, parseThread as _parseThread };
|