@yeaft/webchat-agent 1.0.90 → 1.0.91
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/package.json +1 -1
- package/yeaft/sessions/recovery.js +187 -0
- package/yeaft/sessions/session-crud.js +16 -2
package/package.json
CHANGED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recovery.js — best-effort Session disk self-recovery.
|
|
3
|
+
*
|
|
4
|
+
* Instances can contain old partially migrated Session dirs where the
|
|
5
|
+
* conversation transcript still exists under conversation/messages/*.md, but
|
|
6
|
+
* session.json is missing. This module repairs Session metadata and the
|
|
7
|
+
* coordinator audit-log index only. It deliberately does not convert
|
|
8
|
+
* conversation Markdown into the audit log: the engine/UI history source is
|
|
9
|
+
* ConversationStore under conversation/, not sessions/<id>/messages/.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readFileSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
statSync,
|
|
19
|
+
} from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { writeAtomic, openLog } from '../storage/index.js';
|
|
22
|
+
import { parseMessage, parseSeqFromId } from '../conversation/persist.js';
|
|
23
|
+
import { loadSessionMeta, SESSION_META_FILE } from './session-store.js';
|
|
24
|
+
|
|
25
|
+
const SESSION_ID_RE = /^session_[A-Za-z0-9._-]+$/;
|
|
26
|
+
const DEFAULT_VP_ID = 'omni';
|
|
27
|
+
const AUDIT_INDEX_FILE = 'index.json';
|
|
28
|
+
|
|
29
|
+
export function repairSessionStore(yeaftDir, options = {}) {
|
|
30
|
+
const root = join(yeaftDir, 'sessions');
|
|
31
|
+
if (!existsSync(root)) return { repaired: 0, sessions: [] };
|
|
32
|
+
const sessionIds = safeReadDir(root)
|
|
33
|
+
.filter(name => !name.startsWith('.') && SESSION_ID_RE.test(name))
|
|
34
|
+
.filter(name => isDirectory(join(root, name)));
|
|
35
|
+
const sessions = [];
|
|
36
|
+
for (const sessionId of sessionIds) {
|
|
37
|
+
const result = repairSessionDir(root, sessionId, options);
|
|
38
|
+
if (result.changed) sessions.push(result);
|
|
39
|
+
}
|
|
40
|
+
return { repaired: sessions.length, sessions };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function repairSessionDir(sessionsRoot, sessionId, options = {}) {
|
|
44
|
+
const dir = join(sessionsRoot, sessionId);
|
|
45
|
+
const result = {
|
|
46
|
+
sessionId,
|
|
47
|
+
changed: false,
|
|
48
|
+
metaCreated: false,
|
|
49
|
+
auditIndexRebuilt: false,
|
|
50
|
+
};
|
|
51
|
+
if (!isDirectory(dir)) return result;
|
|
52
|
+
|
|
53
|
+
if (!loadSessionMeta(dir)) {
|
|
54
|
+
const meta = inferSessionMeta(dir, sessionId, options);
|
|
55
|
+
writeAtomic(join(dir, SESSION_META_FILE), JSON.stringify(meta, null, 2));
|
|
56
|
+
result.metaCreated = true;
|
|
57
|
+
result.changed = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (repairAuditIndexIfNeeded(join(dir, 'messages'))) {
|
|
61
|
+
result.auditIndexRebuilt = true;
|
|
62
|
+
result.changed = true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inferSessionMeta(dir, sessionId, options = {}) {
|
|
69
|
+
const first = readFirstConversationMarkdownRow(dir, sessionId);
|
|
70
|
+
const createdAt = first?.time || safeStatTime(dir) || new Date().toISOString();
|
|
71
|
+
const name = inferSessionName(sessionId);
|
|
72
|
+
const roster = Array.isArray(options.defaultRoster)
|
|
73
|
+
? options.defaultRoster.filter(v => typeof v === 'string' && v)
|
|
74
|
+
: [DEFAULT_VP_ID];
|
|
75
|
+
const defaultVpId = typeof options.defaultVpId === 'string'
|
|
76
|
+
? options.defaultVpId
|
|
77
|
+
: (roster.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : (roster[0] || null));
|
|
78
|
+
return {
|
|
79
|
+
id: sessionId,
|
|
80
|
+
name,
|
|
81
|
+
roster,
|
|
82
|
+
defaultVpId,
|
|
83
|
+
announcement: '',
|
|
84
|
+
workDir: typeof options.workDir === 'string' ? options.workDir : '',
|
|
85
|
+
createdAt,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function inferSessionName(sessionId) {
|
|
90
|
+
const raw = sessionId
|
|
91
|
+
.replace(/^session_/, '')
|
|
92
|
+
.replace(/_[A-Z0-9]{8}$/, '')
|
|
93
|
+
.replace(/[-_]+/g, ' ')
|
|
94
|
+
.trim();
|
|
95
|
+
return raw ? raw.replace(/\b\w/g, ch => ch.toUpperCase()) : sessionId;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readFirstConversationMarkdownRow(sessionDir, sessionId) {
|
|
99
|
+
const dirs = [
|
|
100
|
+
join(sessionDir, 'conversation', 'cold'),
|
|
101
|
+
join(sessionDir, 'conversation', 'messages'),
|
|
102
|
+
];
|
|
103
|
+
let first = null;
|
|
104
|
+
for (const dir of dirs) {
|
|
105
|
+
if (!existsSync(dir)) continue;
|
|
106
|
+
for (const file of safeReadDir(dir).filter(f => /^m\d+\.md$/.test(f)).sort()) {
|
|
107
|
+
const msg = parseMessage(safeRead(join(dir, file)));
|
|
108
|
+
if (!msg) continue;
|
|
109
|
+
if (msg.sessionId && msg.sessionId !== sessionId) continue;
|
|
110
|
+
if (!first || compareMarkdownMessages(msg, first) < 0) first = msg;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return first;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function repairAuditIndexIfNeeded(messagesDir) {
|
|
117
|
+
if (!existsSync(messagesDir)) return false;
|
|
118
|
+
const segmentFiles = safeReadDir(messagesDir).filter(f => /^\d+\.jsonl$/.test(f)).sort();
|
|
119
|
+
if (segmentFiles.length === 0) return false;
|
|
120
|
+
|
|
121
|
+
const indexPath = join(messagesDir, AUDIT_INDEX_FILE);
|
|
122
|
+
if (!auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles)) return false;
|
|
123
|
+
|
|
124
|
+
rmSync(indexPath, { force: true });
|
|
125
|
+
const log = openLog(messagesDir);
|
|
126
|
+
log.close();
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function auditIndexNeedsRebuild(messagesDir, indexPath, segmentFiles) {
|
|
131
|
+
const parsed = readJson(indexPath);
|
|
132
|
+
if (!parsed || !Array.isArray(parsed.segments)) return true;
|
|
133
|
+
const indexFiles = parsed.segments.map(seg => seg && seg.file).filter(Boolean).sort();
|
|
134
|
+
if (indexFiles.length !== segmentFiles.length) return true;
|
|
135
|
+
for (let i = 0; i < segmentFiles.length; i++) {
|
|
136
|
+
if (indexFiles[i] !== segmentFiles[i]) return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const byFile = new Map(parsed.segments.map(seg => [seg.file, seg]));
|
|
140
|
+
for (const file of segmentFiles) {
|
|
141
|
+
const seg = byFile.get(file);
|
|
142
|
+
if (!seg) return true;
|
|
143
|
+
const bytes = safeSize(join(messagesDir, file));
|
|
144
|
+
// This catches the broken real-world case: index says an existing segment
|
|
145
|
+
// is empty even though the JSONL file has data. Full validation is left to
|
|
146
|
+
// openLog's rebuild path after we remove the stale index.
|
|
147
|
+
if (bytes > 0 && ((Number(seg.count) || 0) === 0 || (Number(seg.bytes) || 0) === 0)) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compareMarkdownMessages(a, b) {
|
|
153
|
+
const as = parseSeqFromId(a?.id);
|
|
154
|
+
const bs = parseSeqFromId(b?.id);
|
|
155
|
+
if (Number.isFinite(as) && Number.isFinite(bs) && as !== bs) return as - bs;
|
|
156
|
+
return String(a?.time || '').localeCompare(String(b?.time || ''));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function readJson(path) {
|
|
160
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); }
|
|
161
|
+
catch { return null; }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeRead(path) {
|
|
165
|
+
try { return readFileSync(path, 'utf8'); }
|
|
166
|
+
catch { return ''; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function safeReadDir(dir) {
|
|
170
|
+
try { return readdirSync(dir); }
|
|
171
|
+
catch { return []; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isDirectory(path) {
|
|
175
|
+
try { return statSync(path).isDirectory(); }
|
|
176
|
+
catch { return false; }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function safeSize(path) {
|
|
180
|
+
try { return statSync(path).size; }
|
|
181
|
+
catch { return 0; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function safeStatTime(path) {
|
|
185
|
+
try { return statSync(path).mtime.toISOString(); }
|
|
186
|
+
catch { return null; }
|
|
187
|
+
}
|
|
@@ -57,6 +57,7 @@ import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
|
|
|
57
57
|
import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
|
|
58
58
|
import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
|
|
59
59
|
import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
|
|
60
|
+
import { repairSessionStore } from './recovery.js';
|
|
60
61
|
import {
|
|
61
62
|
addOrUpdateManifestSession,
|
|
62
63
|
ensureSessionsManifest,
|
|
@@ -185,6 +186,17 @@ function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
|
|
|
185
186
|
}
|
|
186
187
|
}
|
|
187
188
|
|
|
189
|
+
function repairSessionStoreAndManifest(yeaftDir, options = {}) {
|
|
190
|
+
const repaired = repairSessionStore(yeaftDir, options);
|
|
191
|
+
ensureSessionManifestReady(yeaftDir);
|
|
192
|
+
for (const row of repaired.sessions || []) {
|
|
193
|
+
const dir = join(sessionsRoot(yeaftDir), row.sessionId);
|
|
194
|
+
const meta = loadSessionMeta(dir);
|
|
195
|
+
if (meta) addOrUpdateManifestSession(yeaftDir, meta, dir);
|
|
196
|
+
}
|
|
197
|
+
return repaired;
|
|
198
|
+
}
|
|
199
|
+
|
|
188
200
|
/**
|
|
189
201
|
* One-way compatibility migration for sessions previously stored under a
|
|
190
202
|
* project `.yeaft/sessions` tree. New steady-state discovery uses
|
|
@@ -380,7 +392,9 @@ function scanSortedVpIds(libDir) {
|
|
|
380
392
|
export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
381
393
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
382
394
|
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
383
|
-
|
|
395
|
+
repairSessionStoreAndManifest(yeaftDir, {
|
|
396
|
+
defaultRoster: scanSortedVpIds(libDir),
|
|
397
|
+
});
|
|
384
398
|
const existing = listManifestSessions(yeaftDir).map(row => row.meta);
|
|
385
399
|
if (existing.length > 0) {
|
|
386
400
|
return { seeded: false, sessionId: existing[0].id };
|
|
@@ -718,7 +732,7 @@ export function requireSession(yeaftDir, sessionId) {
|
|
|
718
732
|
|
|
719
733
|
/** Convenience: snapshot all non-archived groups for WS broadcast. */
|
|
720
734
|
export function snapshotSessions(yeaftDir) {
|
|
721
|
-
|
|
735
|
+
repairSessionStoreAndManifest(yeaftDir);
|
|
722
736
|
const byId = new Map();
|
|
723
737
|
for (const row of listManifestSessions(yeaftDir)) {
|
|
724
738
|
byId.set(row.meta.id, row.meta);
|