@yeaft/webchat-agent 1.0.89 → 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
CHANGED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate/project-sessions-to-user.js — copy workDir-backed Yeaft Sessions into
|
|
4
|
+
* the agent-local user Yeaft directory so normal startup discovers them from
|
|
5
|
+
* `<userYeaftDir>/sessions/` without depending on the workDir registry.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node agent/yeaft/migrate/project-sessions-to-user.js <projectDir> [userYeaftDir]
|
|
9
|
+
* node agent/yeaft/migrate/project-sessions-to-user.js --dry-run <projectDir>
|
|
10
|
+
* node agent/yeaft/migrate/project-sessions-to-user.js --overwrite <projectDir>
|
|
11
|
+
*
|
|
12
|
+
* Default userYeaftDir is `~/.yeaft`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
cpSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'fs';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import {
|
|
25
|
+
loadSessionMeta,
|
|
26
|
+
} from '../sessions/session-store.js';
|
|
27
|
+
import {
|
|
28
|
+
normalizeWorkDir,
|
|
29
|
+
scanWorkdirSessions,
|
|
30
|
+
sessionsRoot,
|
|
31
|
+
unregisterSessionWorkDir,
|
|
32
|
+
yeaftDirForWorkDir,
|
|
33
|
+
} from '../sessions/session-crud.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {Object} MigrationOptions
|
|
37
|
+
* @property {boolean} [dryRun]
|
|
38
|
+
* @property {boolean} [overwrite]
|
|
39
|
+
* @property {boolean} [deleteSource]
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copy every readable Session under `<projectDir>/.yeaft/sessions/` into
|
|
44
|
+
* `<userYeaftDir>/sessions/` and copy matching memory scopes. Existing
|
|
45
|
+
* destination Sessions are skipped unless `overwrite` is true.
|
|
46
|
+
*
|
|
47
|
+
* We deliberately unregister migrated Session ids from the legacy workDir
|
|
48
|
+
* registry after a successful copy. Otherwise startup would still prefer the
|
|
49
|
+
* project-backed copy over the newly local copy.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} projectDir
|
|
52
|
+
* @param {string} [userYeaftDir]
|
|
53
|
+
* @param {MigrationOptions} [options]
|
|
54
|
+
* @returns {{ok:boolean, projectDir:string, userYeaftDir:string, scanned:number, copied:number, skipped:number, overwritten:number, removedSources:number, sessions:Array<object>, warnings:string[]}}
|
|
55
|
+
*/
|
|
56
|
+
export function migrateProjectSessionsToUser(projectDir, userYeaftDir = join(homedir(), '.yeaft'), options = {}) {
|
|
57
|
+
const normalizedProjectDir = normalizeWorkDir(projectDir);
|
|
58
|
+
const normalizedUserYeaftDir = normalizeWorkDir(userYeaftDir || join(homedir(), '.yeaft'));
|
|
59
|
+
const dryRun = !!options.dryRun;
|
|
60
|
+
const overwrite = !!options.overwrite;
|
|
61
|
+
const deleteSource = !!options.deleteSource;
|
|
62
|
+
const warnings = [];
|
|
63
|
+
const sessions = [];
|
|
64
|
+
|
|
65
|
+
if (!normalizedProjectDir) {
|
|
66
|
+
throw new Error('projectDir required');
|
|
67
|
+
}
|
|
68
|
+
if (!normalizedUserYeaftDir) {
|
|
69
|
+
throw new Error('userYeaftDir required');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const projectYeaftDir = yeaftDirForWorkDir(normalizedProjectDir);
|
|
73
|
+
const projectSessionsRoot = sessionsRoot(projectYeaftDir);
|
|
74
|
+
const userSessionsRoot = sessionsRoot(normalizedUserYeaftDir);
|
|
75
|
+
const found = scanWorkdirSessions(normalizedProjectDir);
|
|
76
|
+
|
|
77
|
+
let copied = 0;
|
|
78
|
+
let skipped = 0;
|
|
79
|
+
let overwritten = 0;
|
|
80
|
+
let removedSources = 0;
|
|
81
|
+
|
|
82
|
+
if (!dryRun) {
|
|
83
|
+
mkdirSync(userSessionsRoot, { recursive: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const meta of found) {
|
|
87
|
+
const sessionId = meta.id;
|
|
88
|
+
const sourceDir = join(projectSessionsRoot, sessionId);
|
|
89
|
+
const destDir = join(userSessionsRoot, sessionId);
|
|
90
|
+
const row = {
|
|
91
|
+
id: sessionId,
|
|
92
|
+
name: meta.name || sessionId,
|
|
93
|
+
sourceDir,
|
|
94
|
+
destDir,
|
|
95
|
+
status: 'pending',
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (!existsSync(sourceDir) || !loadSessionMeta(sourceDir)) {
|
|
99
|
+
row.status = 'skipped';
|
|
100
|
+
row.reason = 'source_missing_or_corrupt';
|
|
101
|
+
skipped += 1;
|
|
102
|
+
sessions.push(row);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (existsSync(destDir)) {
|
|
107
|
+
if (!overwrite) {
|
|
108
|
+
row.status = 'skipped';
|
|
109
|
+
row.reason = 'destination_exists';
|
|
110
|
+
skipped += 1;
|
|
111
|
+
sessions.push(row);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (!dryRun) rmSync(destDir, { recursive: true, force: true });
|
|
115
|
+
overwritten += 1;
|
|
116
|
+
row.overwritten = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!dryRun) {
|
|
120
|
+
cpSync(sourceDir, destDir, { recursive: true, errorOnExist: false });
|
|
121
|
+
const copiedMemoryDirs = copySessionMemory(projectYeaftDir, normalizedUserYeaftDir, sessionId, { overwrite, dryRun, warnings });
|
|
122
|
+
unregisterSessionWorkDir(normalizedUserYeaftDir, sessionId);
|
|
123
|
+
markMigrated(destDir, normalizedProjectDir);
|
|
124
|
+
if (deleteSource) {
|
|
125
|
+
rmSync(sourceDir, { recursive: true, force: true });
|
|
126
|
+
removeCopiedSourceMemory(copiedMemoryDirs, warnings);
|
|
127
|
+
removedSources += 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
row.status = dryRun ? 'would_copy' : 'copied';
|
|
132
|
+
copied += 1;
|
|
133
|
+
sessions.push(row);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
ok: true,
|
|
138
|
+
projectDir: normalizedProjectDir,
|
|
139
|
+
userYeaftDir: normalizedUserYeaftDir,
|
|
140
|
+
scanned: found.length,
|
|
141
|
+
copied,
|
|
142
|
+
skipped,
|
|
143
|
+
overwritten,
|
|
144
|
+
removedSources,
|
|
145
|
+
sessions,
|
|
146
|
+
warnings,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function copySessionMemory(projectYeaftDir, userYeaftDir, sessionId, { overwrite, dryRun, warnings }) {
|
|
151
|
+
const pairs = [
|
|
152
|
+
[join(projectYeaftDir, 'memory', 'session', sessionId), join(userYeaftDir, 'memory', 'session', sessionId)],
|
|
153
|
+
[join(projectYeaftDir, 'memory', 'sessions', sessionId), join(userYeaftDir, 'memory', 'sessions', sessionId)],
|
|
154
|
+
[join(projectYeaftDir, 'memory', 'group', sessionId), join(userYeaftDir, 'memory', 'group', sessionId)],
|
|
155
|
+
];
|
|
156
|
+
const copied = [];
|
|
157
|
+
for (const [src, dst] of pairs) {
|
|
158
|
+
if (!existsSync(src)) continue;
|
|
159
|
+
if (existsSync(dst)) {
|
|
160
|
+
if (!overwrite) {
|
|
161
|
+
warnings.push(`memory destination exists; skipping ${dst}`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (!dryRun) rmSync(dst, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
if (!dryRun) {
|
|
167
|
+
mkdirSync(join(dst, '..'), { recursive: true });
|
|
168
|
+
cpSync(src, dst, { recursive: true, errorOnExist: false });
|
|
169
|
+
}
|
|
170
|
+
copied.push(src);
|
|
171
|
+
}
|
|
172
|
+
return copied;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function removeCopiedSourceMemory(copiedMemoryDirs, warnings) {
|
|
176
|
+
for (const dir of copiedMemoryDirs) {
|
|
177
|
+
try {
|
|
178
|
+
rmSync(dir, { recursive: true, force: true });
|
|
179
|
+
} catch (err) {
|
|
180
|
+
warnings.push(`failed to remove source memory ${dir}: ${err?.message || err}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function markMigrated(destDir, projectDir) {
|
|
186
|
+
const markerDir = join(destDir, '.migrations');
|
|
187
|
+
mkdirSync(markerDir, { recursive: true });
|
|
188
|
+
writeFileSync(
|
|
189
|
+
join(markerDir, 'project-sessions-to-user.json'),
|
|
190
|
+
`${JSON.stringify({ migratedAt: new Date().toISOString(), sourceProjectDir: projectDir }, null, 2)}\n`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseCliArgs(argv) {
|
|
195
|
+
const args = argv.slice(2);
|
|
196
|
+
const options = { dryRun: false, overwrite: false, deleteSource: false };
|
|
197
|
+
const positional = [];
|
|
198
|
+
for (const arg of args) {
|
|
199
|
+
if (arg === '--dry-run') options.dryRun = true;
|
|
200
|
+
else if (arg === '--overwrite') options.overwrite = true;
|
|
201
|
+
else if (arg === '--delete-source') options.deleteSource = true;
|
|
202
|
+
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
203
|
+
else positional.push(arg);
|
|
204
|
+
}
|
|
205
|
+
return { options, positional };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function printUsage() {
|
|
209
|
+
console.log('Usage: node agent/yeaft/migrate/project-sessions-to-user.js [--dry-run] [--overwrite] [--delete-source] <projectDir> [userYeaftDir]');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
213
|
+
const { options, positional } = parseCliArgs(process.argv);
|
|
214
|
+
if (options.help || positional.length < 1) {
|
|
215
|
+
printUsage();
|
|
216
|
+
process.exitCode = options.help ? 0 : 1;
|
|
217
|
+
} else {
|
|
218
|
+
try {
|
|
219
|
+
const result = migrateProjectSessionsToUser(positional[0], positional[1], options);
|
|
220
|
+
console.log(JSON.stringify(result, null, 2));
|
|
221
|
+
if (result.scanned === 0) process.exitCode = 2;
|
|
222
|
+
} catch (err) {
|
|
223
|
+
console.error(err?.message || String(err));
|
|
224
|
+
process.exitCode = 1;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -38,18 +38,18 @@ import {
|
|
|
38
38
|
existsSync,
|
|
39
39
|
renameSync,
|
|
40
40
|
rmSync,
|
|
41
|
-
cpSync,
|
|
42
41
|
readdirSync,
|
|
43
42
|
statSync,
|
|
44
43
|
mkdirSync,
|
|
45
44
|
readFileSync,
|
|
46
45
|
writeFileSync,
|
|
46
|
+
cpSync,
|
|
47
47
|
} from 'fs';
|
|
48
48
|
import { randomBytes } from 'crypto';
|
|
49
49
|
import { homedir } from 'os';
|
|
50
50
|
import { isAbsolute, join, resolve } from 'path';
|
|
51
51
|
import {
|
|
52
|
-
openSession, createSession,
|
|
52
|
+
openSession, createSession, loadSessionMeta,
|
|
53
53
|
} from './session-store.js';
|
|
54
54
|
import { addVp as rosterAdd, removeVp as rosterRemove, setDefaultVp } from './roster.js';
|
|
55
55
|
import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
|
|
@@ -57,6 +57,15 @@ 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';
|
|
61
|
+
import {
|
|
62
|
+
addOrUpdateManifestSession,
|
|
63
|
+
ensureSessionsManifest,
|
|
64
|
+
hasSessionManifest,
|
|
65
|
+
listManifestSessions,
|
|
66
|
+
removeManifestSession,
|
|
67
|
+
resolveManifestSessionDir,
|
|
68
|
+
} from './session-manifest.js';
|
|
60
69
|
|
|
61
70
|
/**
|
|
62
71
|
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
@@ -156,95 +165,194 @@ export function unregisterSessionWorkDir(defaultYeaftDir, sessionId) {
|
|
|
156
165
|
writeWorkDirRegistry(defaultYeaftDir, registry);
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
function ensureSessionManifestReady(yeaftDir) {
|
|
169
|
+
return ensureSessionsManifest(yeaftDir, {
|
|
170
|
+
sessionsRoot: sessionsRoot(yeaftDir),
|
|
171
|
+
registry: readWorkDirRegistry(yeaftDir),
|
|
172
|
+
yeaftDirForWorkDir,
|
|
173
|
+
sessionsRootForYeaftDir: sessionsRoot,
|
|
174
|
+
copySessionExtras: (projectYeaftDir, sessionId) => copySessionExtras(projectYeaftDir, yeaftDir, sessionId),
|
|
175
|
+
unregisterSessionWorkDir: (sessionId) => unregisterSessionWorkDir(yeaftDir, sessionId),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
|
|
180
|
+
for (const family of ['session', 'sessions', 'group']) {
|
|
181
|
+
const src = join(sourceYeaftDir, 'memory', family, sessionId);
|
|
182
|
+
const dst = join(destYeaftDir, 'memory', family, sessionId);
|
|
183
|
+
if (!existsSync(src) || existsSync(dst)) continue;
|
|
184
|
+
mkdirSync(join(dst, '..'), { recursive: true });
|
|
185
|
+
cpSync(src, dst, { recursive: true, errorOnExist: false });
|
|
186
|
+
}
|
|
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
|
+
|
|
159
200
|
/**
|
|
160
201
|
* One-way compatibility migration for sessions previously stored under a
|
|
161
|
-
* project `.yeaft/sessions` tree. New
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* We copy missing session directories into the user-level root and never
|
|
166
|
-
* overwrite an existing user-level session. The old project directory is left in
|
|
167
|
-
* place as a safety backup; subsequent runtime paths ignore it.
|
|
202
|
+
* project `.yeaft/sessions` tree. New steady-state discovery uses
|
|
203
|
+
* `sessions-manifest.json`; this helper bootstraps that manifest once and
|
|
204
|
+
* reports what happened for boot logs/tests.
|
|
168
205
|
*
|
|
169
206
|
* @param {string} yeaftDir user-level Yeaft root
|
|
170
207
|
* @returns {{migrated:string[], skipped:string[], errors:Array<{sessionId:string,error:string}>}}
|
|
171
208
|
*/
|
|
172
209
|
export function migrateRegisteredWorkDirSessions(yeaftDir) {
|
|
173
210
|
const result = { migrated: [], skipped: [], errors: [] };
|
|
174
|
-
if (!yeaftDir) return result;
|
|
211
|
+
if (!yeaftDir || hasSessionManifest(yeaftDir)) return result;
|
|
212
|
+
|
|
213
|
+
const invalidTargets = new Set();
|
|
175
214
|
const registry = readWorkDirRegistry(yeaftDir);
|
|
176
|
-
const userRoot = sessionsRoot(yeaftDir);
|
|
177
215
|
for (const [sessionId, workDir] of Object.entries(registry)) {
|
|
178
216
|
const normalized = normalizeWorkDir(workDir);
|
|
179
217
|
if (!sessionId || !normalized) continue;
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (existsSync(targetDir)) {
|
|
185
|
-
const targetMeta = loadSessionMeta(targetDir);
|
|
186
|
-
if (targetMeta) {
|
|
187
|
-
result.skipped.push(sessionId);
|
|
188
|
-
} else {
|
|
189
|
-
result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
|
|
190
|
-
}
|
|
191
|
-
continue;
|
|
192
|
-
}
|
|
193
|
-
const sourceMeta = existsSync(sourceDir) ? loadSessionMeta(sourceDir) : null;
|
|
194
|
-
if (!sourceMeta) {
|
|
195
|
-
result.skipped.push(sessionId);
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
mkdirSync(userRoot, { recursive: true });
|
|
199
|
-
tmpDir = join(userRoot, `.migrating-${sessionId}-${randomBytes(6).toString('hex')}`);
|
|
200
|
-
cpSync(sourceDir, tmpDir, { recursive: true, errorOnExist: true });
|
|
201
|
-
const copiedMeta = loadSessionMeta(tmpDir);
|
|
202
|
-
if (!copiedMeta || copiedMeta.id !== sessionId) {
|
|
203
|
-
throw new Error('copied session metadata failed validation');
|
|
204
|
-
}
|
|
205
|
-
renameSync(tmpDir, targetDir);
|
|
206
|
-
tmpDir = null;
|
|
207
|
-
result.migrated.push(sessionId);
|
|
208
|
-
} catch (err) {
|
|
209
|
-
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
210
|
-
result.errors.push({ sessionId, error: err?.message || String(err) });
|
|
218
|
+
const targetDir = join(sessionsRoot(yeaftDir), sessionId);
|
|
219
|
+
if (existsSync(targetDir) && !loadSessionMeta(targetDir)) {
|
|
220
|
+
invalidTargets.add(sessionId);
|
|
221
|
+
result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
|
|
211
222
|
}
|
|
212
223
|
}
|
|
224
|
+
|
|
225
|
+
const bootstrap = ensureSessionManifestReady(yeaftDir);
|
|
226
|
+
result.migrated = Array.isArray(bootstrap.migratedIds) ? bootstrap.migratedIds.slice() : [];
|
|
227
|
+
result.skipped = (Array.isArray(bootstrap.skippedIds) ? bootstrap.skippedIds : [])
|
|
228
|
+
.filter(sessionId => !invalidTargets.has(sessionId));
|
|
213
229
|
return result;
|
|
214
230
|
}
|
|
215
231
|
|
|
216
232
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
233
|
+
* Scan the `.yeaft/sessions/` directory under `workDir` and return every
|
|
234
|
+
* session meta we can read. Read-only: never touches the registry.
|
|
235
|
+
*
|
|
236
|
+
* Each returned record carries `workDir` (the normalized path we scanned).
|
|
237
|
+
* This utility deliberately does NOT decorate `alreadyRegistered` — that
|
|
238
|
+
* cross-references the central workdir registry, which is a separate
|
|
239
|
+
* concern owned by the handler / caller (see
|
|
240
|
+
* `handleYeaftScanWorkdirSessions`). Keeping the utility layer-pure makes
|
|
241
|
+
* it usable from contexts that don't have / don't care about the registry
|
|
242
|
+
* (e.g. CLI tools, future per-workdir snapshots).
|
|
243
|
+
*
|
|
244
|
+
* Returns `[]` for missing dir / empty dir / unreadable dir — never throws.
|
|
245
|
+
* Sort is `createdAt` descending (most-recent first) because the restore
|
|
246
|
+
* UI typically cares about "the session I made yesterday".
|
|
221
247
|
*
|
|
222
248
|
* @param {string} workDir — the working directory to scan.
|
|
223
249
|
* @returns {Array<object>}
|
|
224
250
|
*/
|
|
225
251
|
export function scanWorkdirSessions(workDir) {
|
|
226
|
-
|
|
252
|
+
const normalized = normalizeWorkDir(workDir);
|
|
253
|
+
if (!normalized) return [];
|
|
254
|
+
const groupYeaftDir = yeaftDirForWorkDir(normalized);
|
|
255
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
256
|
+
if (!existsSync(root)) return [];
|
|
257
|
+
const out = [];
|
|
258
|
+
let entries;
|
|
259
|
+
try {
|
|
260
|
+
entries = readdirSync(root);
|
|
261
|
+
} catch {
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
for (const name of entries) {
|
|
265
|
+
if (name.startsWith('.')) continue; // skip .archived-* and dotfiles
|
|
266
|
+
const dir = join(root, name);
|
|
267
|
+
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
|
|
268
|
+
const meta = loadSessionMeta(dir);
|
|
269
|
+
if (!meta) continue;
|
|
270
|
+
out.push({
|
|
271
|
+
...meta,
|
|
272
|
+
workDir: normalized,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
227
276
|
}
|
|
228
277
|
|
|
229
278
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
279
|
+
* Register `(sessionId, workDir)` in the central registry so the next
|
|
280
|
+
* `snapshotSessions()` includes this session.
|
|
281
|
+
*
|
|
282
|
+
* Validates that `<workDir>/.yeaft/sessions/<sessionId>/session.json`
|
|
283
|
+
* exists and is parseable, with legacy `group.json` as a read fallback.
|
|
284
|
+
* Throws:
|
|
285
|
+
* - `not_found` — the session dir is not on disk at this workdir
|
|
286
|
+
* - `corrupt_meta` — the dir exists but session metadata is missing /
|
|
287
|
+
* unreadable / can't be parsed. Surfaced as a distinct
|
|
288
|
+
* code so the UI can tell the user "the file is broken"
|
|
289
|
+
* instead of "you picked the wrong workdir" (review
|
|
290
|
+
* finding I1).
|
|
291
|
+
*
|
|
292
|
+
* Idempotent: if the same `(sessionId, workDir)` is already registered,
|
|
293
|
+
* we still rewrite the entry (with the normalized path) and return the
|
|
294
|
+
* fresh meta — no error.
|
|
233
295
|
*
|
|
234
296
|
* @param {string} defaultYeaftDir
|
|
235
297
|
* @param {string} sessionId
|
|
236
298
|
* @param {string} workDir
|
|
237
|
-
* @returns {object}
|
|
299
|
+
* @returns {object} the session meta, with `workDir` set to the normalized path.
|
|
238
300
|
*/
|
|
239
301
|
export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
|
|
240
302
|
if (!sessionId) throw new SessionCrudError('invalid_session_id', null);
|
|
241
|
-
|
|
303
|
+
const normalized = normalizeWorkDir(workDir);
|
|
304
|
+
if (!normalized) throw new SessionCrudError('invalid_workdir', sessionId);
|
|
305
|
+
const projectYeaftDir = yeaftDirForWorkDir(normalized);
|
|
306
|
+
const sourceDir = join(sessionsRoot(projectYeaftDir), sessionId);
|
|
307
|
+
if (!existsSync(sourceDir)) throw new SessionCrudError('not_found', sessionId);
|
|
308
|
+
const meta = loadSessionMeta(sourceDir);
|
|
309
|
+
if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${sourceDir} (expected session.json or legacy group.json)`);
|
|
310
|
+
|
|
311
|
+
ensureSessionManifestReady(defaultYeaftDir);
|
|
312
|
+
const root = sessionsRoot(defaultYeaftDir);
|
|
313
|
+
const destDir = join(root, sessionId);
|
|
314
|
+
const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
|
|
315
|
+
if (existsSync(destDir)) {
|
|
316
|
+
const existing = loadSessionMeta(destDir);
|
|
317
|
+
if (existing && manifestDir === destDir) return { ...existing, workDir: existing.workDir || normalized };
|
|
318
|
+
throw new SessionCrudError('duplicate', sessionId, `session already exists at ${destDir}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
mkdirSync(root, { recursive: true });
|
|
322
|
+
cpSync(sourceDir, destDir, { recursive: true, errorOnExist: true });
|
|
323
|
+
const importedMeta = { ...meta, workDir: normalized };
|
|
324
|
+
const handle = openSession(root, sessionId);
|
|
325
|
+
try {
|
|
326
|
+
handle.saveMeta(importedMeta);
|
|
327
|
+
} finally {
|
|
328
|
+
handle.close();
|
|
329
|
+
}
|
|
330
|
+
copySessionExtras(projectYeaftDir, defaultYeaftDir, sessionId);
|
|
331
|
+
addOrUpdateManifestSession(defaultYeaftDir, importedMeta, destDir);
|
|
332
|
+
unregisterSessionWorkDir(defaultYeaftDir, sessionId);
|
|
333
|
+
return importedMeta;
|
|
242
334
|
}
|
|
243
335
|
|
|
244
336
|
export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
337
|
+
if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
|
|
338
|
+
|
|
339
|
+
const manifestReady = hasSessionManifest(defaultYeaftDir);
|
|
340
|
+
ensureSessionManifestReady(defaultYeaftDir);
|
|
341
|
+
const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
|
|
342
|
+
if (manifestDir) return join(manifestDir, '..', '..');
|
|
343
|
+
if (manifestReady) return defaultYeaftDir;
|
|
344
|
+
|
|
345
|
+
const registry = readWorkDirRegistry(defaultYeaftDir);
|
|
346
|
+
const workDir = normalizeWorkDir(registry[sessionId]);
|
|
347
|
+
if (workDir) {
|
|
348
|
+
const candidate = yeaftDirForWorkDir(workDir);
|
|
349
|
+
const candidateDir = join(sessionsRoot(candidate), sessionId);
|
|
350
|
+
if (existsSync(candidateDir) && loadSessionMeta(candidateDir)) return candidate;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const defaultSessionDir = join(sessionsRoot(defaultYeaftDir), sessionId);
|
|
354
|
+
if (existsSync(defaultSessionDir) && loadSessionMeta(defaultSessionDir)) return defaultYeaftDir;
|
|
355
|
+
|
|
248
356
|
return defaultYeaftDir;
|
|
249
357
|
}
|
|
250
358
|
|
|
@@ -284,7 +392,10 @@ function scanSortedVpIds(libDir) {
|
|
|
284
392
|
export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
285
393
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
286
394
|
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
287
|
-
|
|
395
|
+
repairSessionStoreAndManifest(yeaftDir, {
|
|
396
|
+
defaultRoster: scanSortedVpIds(libDir),
|
|
397
|
+
});
|
|
398
|
+
const existing = listManifestSessions(yeaftDir).map(row => row.meta);
|
|
288
399
|
if (existing.length > 0) {
|
|
289
400
|
return { seeded: false, sessionId: existing[0].id };
|
|
290
401
|
}
|
|
@@ -301,6 +412,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
301
412
|
defaultVpId,
|
|
302
413
|
memoryRoot,
|
|
303
414
|
});
|
|
415
|
+
const meta = group.getMeta();
|
|
416
|
+
if (meta) addOrUpdateManifestSession(yeaftDir, meta, join(sessionsRoot(yeaftDir), meta.id));
|
|
304
417
|
return {
|
|
305
418
|
seeded: created,
|
|
306
419
|
sessionId: group.id,
|
|
@@ -322,7 +435,9 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
322
435
|
export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
323
436
|
const input = spec || {};
|
|
324
437
|
const normalizedWorkDir = normalizeWorkDir(input.workDir);
|
|
325
|
-
|
|
438
|
+
ensureSessionManifestReady(yeaftDir);
|
|
439
|
+
const groupYeaftDir = yeaftDir;
|
|
440
|
+
const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
|
|
326
441
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
327
442
|
const name = String(input.name || '').trim();
|
|
328
443
|
if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
|
|
@@ -348,7 +463,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
348
463
|
if (!defaultVpId) defaultVpId = roster[0] || null;
|
|
349
464
|
|
|
350
465
|
const id = makeSessionId(name);
|
|
351
|
-
const root = sessionsRoot(
|
|
466
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
352
467
|
if (existsSync(join(root, id))) {
|
|
353
468
|
// Extremely unlikely (ulid suffix), but surface deterministically.
|
|
354
469
|
throw new SessionCrudError('duplicate', id);
|
|
@@ -357,7 +472,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
357
472
|
const handle = createSession(root, { id, name, roster, defaultVpId, workDir: normalizedWorkDir });
|
|
358
473
|
const meta = handle.getMeta();
|
|
359
474
|
handle.close();
|
|
360
|
-
|
|
475
|
+
addOrUpdateManifestSession(yeaftDir, meta, join(root, id));
|
|
361
476
|
|
|
362
477
|
// Per-session config (v1: model only). We always create an empty
|
|
363
478
|
// config.json so hand-editing tools can find a session-level override
|
|
@@ -449,12 +564,14 @@ export function updateSessionConfig(yeaftDir, sessionId, partial) {
|
|
|
449
564
|
* own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
|
|
450
565
|
*/
|
|
451
566
|
export function archiveSession(yeaftDir, sessionId) {
|
|
452
|
-
const
|
|
567
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
568
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
453
569
|
const srcDir = join(root, sessionId);
|
|
454
570
|
// Idempotent — nothing on disk, nothing to archive. Workdir-registry is
|
|
455
571
|
// still cleared in case it points at a stale row.
|
|
456
572
|
if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
|
|
457
573
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
574
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
458
575
|
return { sessionId, archivedAs: null, alreadyGone: true };
|
|
459
576
|
}
|
|
460
577
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -463,6 +580,7 @@ export function archiveSession(yeaftDir, sessionId) {
|
|
|
463
580
|
const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
|
|
464
581
|
renameSync(srcDir, dstDir);
|
|
465
582
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
583
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
466
584
|
return { sessionId, archivedAs: dstDir, alreadyGone: false };
|
|
467
585
|
}
|
|
468
586
|
|
|
@@ -479,8 +597,9 @@ export function archiveSession(yeaftDir, sessionId) {
|
|
|
479
597
|
* delete cleans up legacy state too.
|
|
480
598
|
*/
|
|
481
599
|
export function deleteSession(yeaftDir, sessionId, options = {}) {
|
|
482
|
-
const
|
|
483
|
-
const
|
|
600
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
601
|
+
const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
|
|
602
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
484
603
|
const srcDir = join(root, sessionId);
|
|
485
604
|
const liveExists = existsSync(srcDir) && !!loadSessionMeta(srcDir);
|
|
486
605
|
|
|
@@ -520,6 +639,7 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
|
|
|
520
639
|
}
|
|
521
640
|
|
|
522
641
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
642
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
523
643
|
return {
|
|
524
644
|
sessionId,
|
|
525
645
|
deleted: liveExists,
|
|
@@ -601,7 +721,8 @@ export function setSessionDefaultVp(yeaftDir, sessionId, vpId) {
|
|
|
601
721
|
}
|
|
602
722
|
|
|
603
723
|
export function requireSession(yeaftDir, sessionId) {
|
|
604
|
-
const
|
|
724
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
725
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
605
726
|
const dir = join(root, sessionId);
|
|
606
727
|
if (!existsSync(dir) || !loadSessionMeta(dir)) {
|
|
607
728
|
throw new SessionCrudError('not_found', sessionId);
|
|
@@ -611,14 +732,10 @@ export function requireSession(yeaftDir, sessionId) {
|
|
|
611
732
|
|
|
612
733
|
/** Convenience: snapshot all non-archived groups for WS broadcast. */
|
|
613
734
|
export function snapshotSessions(yeaftDir) {
|
|
735
|
+
repairSessionStoreAndManifest(yeaftDir);
|
|
614
736
|
const byId = new Map();
|
|
615
|
-
for (const
|
|
616
|
-
byId.set(
|
|
617
|
-
}
|
|
618
|
-
const registry = readWorkDirRegistry(yeaftDir);
|
|
619
|
-
for (const [sessionId, workDir] of Object.entries(registry)) {
|
|
620
|
-
const meta = byId.get(sessionId);
|
|
621
|
-
if (meta && !meta.workDir) meta.workDir = normalizeWorkDir(workDir);
|
|
737
|
+
for (const row of listManifestSessions(yeaftDir)) {
|
|
738
|
+
byId.set(row.meta.id, row.meta);
|
|
622
739
|
}
|
|
623
740
|
// Attach per-group config overrides (v1: just `model`). Frontend can
|
|
624
741
|
// render the effective model without re-querying.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-manifest.js — canonical index for Yeaft Session folders.
|
|
3
|
+
*
|
|
4
|
+
* Boot flow:
|
|
5
|
+
* 1. If `<yeaftDir>/sessions-manifest.json` exists, load it and trust only
|
|
6
|
+
* valid entries whose folder still contains readable session metadata.
|
|
7
|
+
* 2. If it does not exist, bootstrap once from legacy folder-based storage:
|
|
8
|
+
* copy registered workDir-backed Sessions into `<yeaftDir>/sessions/`, then
|
|
9
|
+
* index `<yeaftDir>/sessions/` into the manifest.
|
|
10
|
+
*
|
|
11
|
+
* The legacy `group-workdirs.json` registry remains readable for bootstrap only.
|
|
12
|
+
* New steady-state discovery should use this manifest.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
cpSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
readdirSync,
|
|
21
|
+
rmSync,
|
|
22
|
+
statSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { loadSessionMeta } from './session-store.js';
|
|
27
|
+
|
|
28
|
+
export const SESSIONS_MANIFEST_FILE = 'sessions-manifest.json';
|
|
29
|
+
const MANIFEST_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
export function sessionManifestPath(yeaftDir) {
|
|
32
|
+
return join(yeaftDir, SESSIONS_MANIFEST_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hasSessionManifest(yeaftDir) {
|
|
36
|
+
return !!yeaftDir && existsSync(sessionManifestPath(yeaftDir));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadSessionsManifest(yeaftDir) {
|
|
40
|
+
if (!hasSessionManifest(yeaftDir)) return null;
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(readFileSync(sessionManifestPath(yeaftDir), 'utf8'));
|
|
43
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
44
|
+
if (!Array.isArray(parsed.sessions)) return null;
|
|
45
|
+
return {
|
|
46
|
+
version: Number(parsed.version) || MANIFEST_VERSION,
|
|
47
|
+
generatedAt: typeof parsed.generatedAt === 'string' ? parsed.generatedAt : '',
|
|
48
|
+
sessions: parsed.sessions
|
|
49
|
+
.filter(row => row && typeof row === 'object' && typeof row.id === 'string' && typeof row.path === 'string')
|
|
50
|
+
.map(row => ({
|
|
51
|
+
id: row.id,
|
|
52
|
+
path: row.path,
|
|
53
|
+
name: typeof row.name === 'string' ? row.name : row.id,
|
|
54
|
+
createdAt: typeof row.createdAt === 'string' ? row.createdAt : '',
|
|
55
|
+
workDir: typeof row.workDir === 'string' ? row.workDir : '',
|
|
56
|
+
})),
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writeSessionsManifest(yeaftDir, sessions) {
|
|
64
|
+
if (!yeaftDir) throw new Error('yeaftDir required');
|
|
65
|
+
mkdirSync(yeaftDir, { recursive: true });
|
|
66
|
+
const manifest = {
|
|
67
|
+
version: MANIFEST_VERSION,
|
|
68
|
+
generatedAt: new Date().toISOString(),
|
|
69
|
+
sessions: dedupeSessions(sessions).sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))),
|
|
70
|
+
};
|
|
71
|
+
writeFileSync(sessionManifestPath(yeaftDir), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
72
|
+
return manifest;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildManifestFromLocalSessions(yeaftDir, sessionsRoot) {
|
|
76
|
+
const rows = [];
|
|
77
|
+
if (!sessionsRoot || !existsSync(sessionsRoot)) return rows;
|
|
78
|
+
let entries;
|
|
79
|
+
try {
|
|
80
|
+
entries = readdirSync(sessionsRoot);
|
|
81
|
+
} catch {
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
for (const name of entries) {
|
|
85
|
+
if (name.startsWith('.')) continue;
|
|
86
|
+
const dir = join(sessionsRoot, name);
|
|
87
|
+
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
|
|
88
|
+
const meta = loadSessionMeta(dir);
|
|
89
|
+
if (!meta) continue;
|
|
90
|
+
rows.push(manifestRowFromMeta(meta, dir));
|
|
91
|
+
}
|
|
92
|
+
return rows;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function listManifestSessions(yeaftDir) {
|
|
96
|
+
const manifest = loadSessionsManifest(yeaftDir);
|
|
97
|
+
if (!manifest) return [];
|
|
98
|
+
const rows = [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const entry of manifest.sessions) {
|
|
101
|
+
if (seen.has(entry.id)) continue;
|
|
102
|
+
seen.add(entry.id);
|
|
103
|
+
const meta = loadSessionMeta(entry.path);
|
|
104
|
+
if (!meta) continue;
|
|
105
|
+
rows.push({ meta, dir: entry.path });
|
|
106
|
+
}
|
|
107
|
+
return rows.sort((a, b) => String(a.meta.createdAt || '').localeCompare(String(b.meta.createdAt || '')));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function resolveManifestSessionDir(yeaftDir, sessionId) {
|
|
111
|
+
const manifest = loadSessionsManifest(yeaftDir);
|
|
112
|
+
if (!manifest || !sessionId) return null;
|
|
113
|
+
const entry = manifest.sessions.find(row => row.id === sessionId);
|
|
114
|
+
if (!entry) return null;
|
|
115
|
+
if (!loadSessionMeta(entry.path)) return null;
|
|
116
|
+
return entry.path;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bootstrap the manifest from legacy storage if it does not exist.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} yeaftDir
|
|
123
|
+
* @param {{sessionsRoot:string, registry?:object, yeaftDirForWorkDir:(workDir:string)=>string, sessionsRootForYeaftDir:(dir:string)=>string, copySessionExtras?:(projectYeaftDir:string, sessionId:string)=>void, unregisterSessionWorkDir?:(sessionId:string)=>void}} options
|
|
124
|
+
*/
|
|
125
|
+
export function ensureSessionsManifest(yeaftDir, options) {
|
|
126
|
+
if (hasSessionManifest(yeaftDir)) {
|
|
127
|
+
return { created: false, migrated: 0, skipped: 0, manifest: loadSessionsManifest(yeaftDir) };
|
|
128
|
+
}
|
|
129
|
+
const root = options.sessionsRoot;
|
|
130
|
+
mkdirSync(root, { recursive: true });
|
|
131
|
+
|
|
132
|
+
let migrated = 0;
|
|
133
|
+
let skipped = 0;
|
|
134
|
+
const migratedIds = [];
|
|
135
|
+
const skippedIds = [];
|
|
136
|
+
const registry = options.registry && typeof options.registry === 'object' ? options.registry : {};
|
|
137
|
+
for (const [sessionId, workDir] of Object.entries(registry)) {
|
|
138
|
+
if (!sessionId || !workDir) continue;
|
|
139
|
+
const projectYeaftDir = options.yeaftDirForWorkDir(workDir);
|
|
140
|
+
const projectRoot = options.sessionsRootForYeaftDir(projectYeaftDir);
|
|
141
|
+
const sourceDir = join(projectRoot, sessionId);
|
|
142
|
+
const destDir = join(root, sessionId);
|
|
143
|
+
const meta = existsSync(sourceDir) ? loadSessionMeta(sourceDir) : null;
|
|
144
|
+
if (!meta) {
|
|
145
|
+
skipped += 1;
|
|
146
|
+
skippedIds.push(sessionId);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (existsSync(destDir)) {
|
|
150
|
+
if (loadSessionMeta(destDir)) {
|
|
151
|
+
skipped += 1;
|
|
152
|
+
skippedIds.push(sessionId);
|
|
153
|
+
if (typeof options.unregisterSessionWorkDir === 'function') {
|
|
154
|
+
options.unregisterSessionWorkDir(sessionId);
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
skipped += 1;
|
|
159
|
+
skippedIds.push(sessionId);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
cpSync(sourceDir, destDir, { recursive: true, errorOnExist: false });
|
|
163
|
+
migrated += 1;
|
|
164
|
+
migratedIds.push(sessionId);
|
|
165
|
+
if (typeof options.copySessionExtras === 'function') {
|
|
166
|
+
options.copySessionExtras(projectYeaftDir, sessionId);
|
|
167
|
+
}
|
|
168
|
+
if (typeof options.unregisterSessionWorkDir === 'function') {
|
|
169
|
+
options.unregisterSessionWorkDir(sessionId);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const manifest = writeSessionsManifest(yeaftDir, buildManifestFromLocalSessions(yeaftDir, root));
|
|
174
|
+
return { created: true, migrated, skipped, migratedIds, skippedIds, manifest };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function addOrUpdateManifestSession(yeaftDir, meta, dir) {
|
|
178
|
+
const current = loadSessionsManifest(yeaftDir) || { sessions: [] };
|
|
179
|
+
const rows = current.sessions.filter(row => row.id !== meta.id);
|
|
180
|
+
rows.push(manifestRowFromMeta(meta, dir));
|
|
181
|
+
return writeSessionsManifest(yeaftDir, rows);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function removeManifestSession(yeaftDir, sessionId) {
|
|
185
|
+
const current = loadSessionsManifest(yeaftDir);
|
|
186
|
+
if (!current) return null;
|
|
187
|
+
return writeSessionsManifest(yeaftDir, current.sessions.filter(row => row.id !== sessionId));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function manifestRowFromMeta(meta, dir) {
|
|
191
|
+
return {
|
|
192
|
+
id: meta.id,
|
|
193
|
+
name: meta.name || meta.id,
|
|
194
|
+
path: dir,
|
|
195
|
+
createdAt: meta.createdAt || '',
|
|
196
|
+
workDir: meta.workDir || '',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function dedupeSessions(sessions) {
|
|
201
|
+
const byId = new Map();
|
|
202
|
+
for (const row of sessions || []) {
|
|
203
|
+
if (!row || !row.id || !row.path) continue;
|
|
204
|
+
byId.set(row.id, {
|
|
205
|
+
id: row.id,
|
|
206
|
+
name: row.name || row.id,
|
|
207
|
+
path: row.path,
|
|
208
|
+
createdAt: row.createdAt || '',
|
|
209
|
+
workDir: row.workDir || '',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return Array.from(byId.values());
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function __testRemoveSessionsManifest(yeaftDir) {
|
|
216
|
+
rmSync(sessionManifestPath(yeaftDir), { force: true });
|
|
217
|
+
}
|