@yeaft/webchat-agent 1.0.89 → 1.0.90
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
|
+
}
|
|
@@ -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,14 @@ 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 {
|
|
61
|
+
addOrUpdateManifestSession,
|
|
62
|
+
ensureSessionsManifest,
|
|
63
|
+
hasSessionManifest,
|
|
64
|
+
listManifestSessions,
|
|
65
|
+
removeManifestSession,
|
|
66
|
+
resolveManifestSessionDir,
|
|
67
|
+
} from './session-manifest.js';
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
70
|
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
@@ -156,95 +164,183 @@ export function unregisterSessionWorkDir(defaultYeaftDir, sessionId) {
|
|
|
156
164
|
writeWorkDirRegistry(defaultYeaftDir, registry);
|
|
157
165
|
}
|
|
158
166
|
|
|
167
|
+
function ensureSessionManifestReady(yeaftDir) {
|
|
168
|
+
return ensureSessionsManifest(yeaftDir, {
|
|
169
|
+
sessionsRoot: sessionsRoot(yeaftDir),
|
|
170
|
+
registry: readWorkDirRegistry(yeaftDir),
|
|
171
|
+
yeaftDirForWorkDir,
|
|
172
|
+
sessionsRootForYeaftDir: sessionsRoot,
|
|
173
|
+
copySessionExtras: (projectYeaftDir, sessionId) => copySessionExtras(projectYeaftDir, yeaftDir, sessionId),
|
|
174
|
+
unregisterSessionWorkDir: (sessionId) => unregisterSessionWorkDir(yeaftDir, sessionId),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function copySessionExtras(sourceYeaftDir, destYeaftDir, sessionId) {
|
|
179
|
+
for (const family of ['session', 'sessions', 'group']) {
|
|
180
|
+
const src = join(sourceYeaftDir, 'memory', family, sessionId);
|
|
181
|
+
const dst = join(destYeaftDir, 'memory', family, sessionId);
|
|
182
|
+
if (!existsSync(src) || existsSync(dst)) continue;
|
|
183
|
+
mkdirSync(join(dst, '..'), { recursive: true });
|
|
184
|
+
cpSync(src, dst, { recursive: true, errorOnExist: false });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
159
188
|
/**
|
|
160
189
|
* 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.
|
|
190
|
+
* project `.yeaft/sessions` tree. New steady-state discovery uses
|
|
191
|
+
* `sessions-manifest.json`; this helper bootstraps that manifest once and
|
|
192
|
+
* reports what happened for boot logs/tests.
|
|
168
193
|
*
|
|
169
194
|
* @param {string} yeaftDir user-level Yeaft root
|
|
170
195
|
* @returns {{migrated:string[], skipped:string[], errors:Array<{sessionId:string,error:string}>}}
|
|
171
196
|
*/
|
|
172
197
|
export function migrateRegisteredWorkDirSessions(yeaftDir) {
|
|
173
198
|
const result = { migrated: [], skipped: [], errors: [] };
|
|
174
|
-
if (!yeaftDir) return result;
|
|
199
|
+
if (!yeaftDir || hasSessionManifest(yeaftDir)) return result;
|
|
200
|
+
|
|
201
|
+
const invalidTargets = new Set();
|
|
175
202
|
const registry = readWorkDirRegistry(yeaftDir);
|
|
176
|
-
const userRoot = sessionsRoot(yeaftDir);
|
|
177
203
|
for (const [sessionId, workDir] of Object.entries(registry)) {
|
|
178
204
|
const normalized = normalizeWorkDir(workDir);
|
|
179
205
|
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) });
|
|
206
|
+
const targetDir = join(sessionsRoot(yeaftDir), sessionId);
|
|
207
|
+
if (existsSync(targetDir) && !loadSessionMeta(targetDir)) {
|
|
208
|
+
invalidTargets.add(sessionId);
|
|
209
|
+
result.errors.push({ sessionId, error: 'target session directory exists but session metadata is invalid' });
|
|
211
210
|
}
|
|
212
211
|
}
|
|
212
|
+
|
|
213
|
+
const bootstrap = ensureSessionManifestReady(yeaftDir);
|
|
214
|
+
result.migrated = Array.isArray(bootstrap.migratedIds) ? bootstrap.migratedIds.slice() : [];
|
|
215
|
+
result.skipped = (Array.isArray(bootstrap.skippedIds) ? bootstrap.skippedIds : [])
|
|
216
|
+
.filter(sessionId => !invalidTargets.has(sessionId));
|
|
213
217
|
return result;
|
|
214
218
|
}
|
|
215
219
|
|
|
216
220
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
+
* Scan the `.yeaft/sessions/` directory under `workDir` and return every
|
|
222
|
+
* session meta we can read. Read-only: never touches the registry.
|
|
223
|
+
*
|
|
224
|
+
* Each returned record carries `workDir` (the normalized path we scanned).
|
|
225
|
+
* This utility deliberately does NOT decorate `alreadyRegistered` — that
|
|
226
|
+
* cross-references the central workdir registry, which is a separate
|
|
227
|
+
* concern owned by the handler / caller (see
|
|
228
|
+
* `handleYeaftScanWorkdirSessions`). Keeping the utility layer-pure makes
|
|
229
|
+
* it usable from contexts that don't have / don't care about the registry
|
|
230
|
+
* (e.g. CLI tools, future per-workdir snapshots).
|
|
231
|
+
*
|
|
232
|
+
* Returns `[]` for missing dir / empty dir / unreadable dir — never throws.
|
|
233
|
+
* Sort is `createdAt` descending (most-recent first) because the restore
|
|
234
|
+
* UI typically cares about "the session I made yesterday".
|
|
221
235
|
*
|
|
222
236
|
* @param {string} workDir — the working directory to scan.
|
|
223
237
|
* @returns {Array<object>}
|
|
224
238
|
*/
|
|
225
239
|
export function scanWorkdirSessions(workDir) {
|
|
226
|
-
|
|
240
|
+
const normalized = normalizeWorkDir(workDir);
|
|
241
|
+
if (!normalized) return [];
|
|
242
|
+
const groupYeaftDir = yeaftDirForWorkDir(normalized);
|
|
243
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
244
|
+
if (!existsSync(root)) return [];
|
|
245
|
+
const out = [];
|
|
246
|
+
let entries;
|
|
247
|
+
try {
|
|
248
|
+
entries = readdirSync(root);
|
|
249
|
+
} catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
for (const name of entries) {
|
|
253
|
+
if (name.startsWith('.')) continue; // skip .archived-* and dotfiles
|
|
254
|
+
const dir = join(root, name);
|
|
255
|
+
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
|
|
256
|
+
const meta = loadSessionMeta(dir);
|
|
257
|
+
if (!meta) continue;
|
|
258
|
+
out.push({
|
|
259
|
+
...meta,
|
|
260
|
+
workDir: normalized,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return out.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
227
264
|
}
|
|
228
265
|
|
|
229
266
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
267
|
+
* Register `(sessionId, workDir)` in the central registry so the next
|
|
268
|
+
* `snapshotSessions()` includes this session.
|
|
269
|
+
*
|
|
270
|
+
* Validates that `<workDir>/.yeaft/sessions/<sessionId>/session.json`
|
|
271
|
+
* exists and is parseable, with legacy `group.json` as a read fallback.
|
|
272
|
+
* Throws:
|
|
273
|
+
* - `not_found` — the session dir is not on disk at this workdir
|
|
274
|
+
* - `corrupt_meta` — the dir exists but session metadata is missing /
|
|
275
|
+
* unreadable / can't be parsed. Surfaced as a distinct
|
|
276
|
+
* code so the UI can tell the user "the file is broken"
|
|
277
|
+
* instead of "you picked the wrong workdir" (review
|
|
278
|
+
* finding I1).
|
|
279
|
+
*
|
|
280
|
+
* Idempotent: if the same `(sessionId, workDir)` is already registered,
|
|
281
|
+
* we still rewrite the entry (with the normalized path) and return the
|
|
282
|
+
* fresh meta — no error.
|
|
233
283
|
*
|
|
234
284
|
* @param {string} defaultYeaftDir
|
|
235
285
|
* @param {string} sessionId
|
|
236
286
|
* @param {string} workDir
|
|
237
|
-
* @returns {object}
|
|
287
|
+
* @returns {object} the session meta, with `workDir` set to the normalized path.
|
|
238
288
|
*/
|
|
239
289
|
export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
|
|
240
290
|
if (!sessionId) throw new SessionCrudError('invalid_session_id', null);
|
|
241
|
-
|
|
291
|
+
const normalized = normalizeWorkDir(workDir);
|
|
292
|
+
if (!normalized) throw new SessionCrudError('invalid_workdir', sessionId);
|
|
293
|
+
const projectYeaftDir = yeaftDirForWorkDir(normalized);
|
|
294
|
+
const sourceDir = join(sessionsRoot(projectYeaftDir), sessionId);
|
|
295
|
+
if (!existsSync(sourceDir)) throw new SessionCrudError('not_found', sessionId);
|
|
296
|
+
const meta = loadSessionMeta(sourceDir);
|
|
297
|
+
if (!meta) throw new SessionCrudError('corrupt_meta', sessionId, `session metadata missing or unreadable at ${sourceDir} (expected session.json or legacy group.json)`);
|
|
298
|
+
|
|
299
|
+
ensureSessionManifestReady(defaultYeaftDir);
|
|
300
|
+
const root = sessionsRoot(defaultYeaftDir);
|
|
301
|
+
const destDir = join(root, sessionId);
|
|
302
|
+
const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
|
|
303
|
+
if (existsSync(destDir)) {
|
|
304
|
+
const existing = loadSessionMeta(destDir);
|
|
305
|
+
if (existing && manifestDir === destDir) return { ...existing, workDir: existing.workDir || normalized };
|
|
306
|
+
throw new SessionCrudError('duplicate', sessionId, `session already exists at ${destDir}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
mkdirSync(root, { recursive: true });
|
|
310
|
+
cpSync(sourceDir, destDir, { recursive: true, errorOnExist: true });
|
|
311
|
+
const importedMeta = { ...meta, workDir: normalized };
|
|
312
|
+
const handle = openSession(root, sessionId);
|
|
313
|
+
try {
|
|
314
|
+
handle.saveMeta(importedMeta);
|
|
315
|
+
} finally {
|
|
316
|
+
handle.close();
|
|
317
|
+
}
|
|
318
|
+
copySessionExtras(projectYeaftDir, defaultYeaftDir, sessionId);
|
|
319
|
+
addOrUpdateManifestSession(defaultYeaftDir, importedMeta, destDir);
|
|
320
|
+
unregisterSessionWorkDir(defaultYeaftDir, sessionId);
|
|
321
|
+
return importedMeta;
|
|
242
322
|
}
|
|
243
323
|
|
|
244
324
|
export function resolveSessionYeaftDir(defaultYeaftDir, sessionId) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
325
|
+
if (!defaultYeaftDir || !sessionId) return defaultYeaftDir;
|
|
326
|
+
|
|
327
|
+
const manifestReady = hasSessionManifest(defaultYeaftDir);
|
|
328
|
+
ensureSessionManifestReady(defaultYeaftDir);
|
|
329
|
+
const manifestDir = resolveManifestSessionDir(defaultYeaftDir, sessionId);
|
|
330
|
+
if (manifestDir) return join(manifestDir, '..', '..');
|
|
331
|
+
if (manifestReady) return defaultYeaftDir;
|
|
332
|
+
|
|
333
|
+
const registry = readWorkDirRegistry(defaultYeaftDir);
|
|
334
|
+
const workDir = normalizeWorkDir(registry[sessionId]);
|
|
335
|
+
if (workDir) {
|
|
336
|
+
const candidate = yeaftDirForWorkDir(workDir);
|
|
337
|
+
const candidateDir = join(sessionsRoot(candidate), sessionId);
|
|
338
|
+
if (existsSync(candidateDir) && loadSessionMeta(candidateDir)) return candidate;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const defaultSessionDir = join(sessionsRoot(defaultYeaftDir), sessionId);
|
|
342
|
+
if (existsSync(defaultSessionDir) && loadSessionMeta(defaultSessionDir)) return defaultYeaftDir;
|
|
343
|
+
|
|
248
344
|
return defaultYeaftDir;
|
|
249
345
|
}
|
|
250
346
|
|
|
@@ -284,7 +380,8 @@ function scanSortedVpIds(libDir) {
|
|
|
284
380
|
export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
285
381
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
286
382
|
const memoryRoot = options.memoryRoot || DEFAULT_MEMORY_ROOT;
|
|
287
|
-
|
|
383
|
+
ensureSessionManifestReady(yeaftDir);
|
|
384
|
+
const existing = listManifestSessions(yeaftDir).map(row => row.meta);
|
|
288
385
|
if (existing.length > 0) {
|
|
289
386
|
return { seeded: false, sessionId: existing[0].id };
|
|
290
387
|
}
|
|
@@ -301,6 +398,8 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
301
398
|
defaultVpId,
|
|
302
399
|
memoryRoot,
|
|
303
400
|
});
|
|
401
|
+
const meta = group.getMeta();
|
|
402
|
+
if (meta) addOrUpdateManifestSession(yeaftDir, meta, join(sessionsRoot(yeaftDir), meta.id));
|
|
304
403
|
return {
|
|
305
404
|
seeded: created,
|
|
306
405
|
sessionId: group.id,
|
|
@@ -322,7 +421,9 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
|
|
|
322
421
|
export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
323
422
|
const input = spec || {};
|
|
324
423
|
const normalizedWorkDir = normalizeWorkDir(input.workDir);
|
|
325
|
-
|
|
424
|
+
ensureSessionManifestReady(yeaftDir);
|
|
425
|
+
const groupYeaftDir = yeaftDir;
|
|
426
|
+
const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
|
|
326
427
|
const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
|
|
327
428
|
const name = String(input.name || '').trim();
|
|
328
429
|
if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
|
|
@@ -348,7 +449,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
348
449
|
if (!defaultVpId) defaultVpId = roster[0] || null;
|
|
349
450
|
|
|
350
451
|
const id = makeSessionId(name);
|
|
351
|
-
const root = sessionsRoot(
|
|
452
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
352
453
|
if (existsSync(join(root, id))) {
|
|
353
454
|
// Extremely unlikely (ulid suffix), but surface deterministically.
|
|
354
455
|
throw new SessionCrudError('duplicate', id);
|
|
@@ -357,7 +458,7 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
|
|
|
357
458
|
const handle = createSession(root, { id, name, roster, defaultVpId, workDir: normalizedWorkDir });
|
|
358
459
|
const meta = handle.getMeta();
|
|
359
460
|
handle.close();
|
|
360
|
-
|
|
461
|
+
addOrUpdateManifestSession(yeaftDir, meta, join(root, id));
|
|
361
462
|
|
|
362
463
|
// Per-session config (v1: model only). We always create an empty
|
|
363
464
|
// config.json so hand-editing tools can find a session-level override
|
|
@@ -449,12 +550,14 @@ export function updateSessionConfig(yeaftDir, sessionId, partial) {
|
|
|
449
550
|
* own second-confirm modal (acceptance #4 in task-334-slice-specs.md 334m).
|
|
450
551
|
*/
|
|
451
552
|
export function archiveSession(yeaftDir, sessionId) {
|
|
452
|
-
const
|
|
553
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
554
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
453
555
|
const srcDir = join(root, sessionId);
|
|
454
556
|
// Idempotent — nothing on disk, nothing to archive. Workdir-registry is
|
|
455
557
|
// still cleared in case it points at a stale row.
|
|
456
558
|
if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
|
|
457
559
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
560
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
458
561
|
return { sessionId, archivedAs: null, alreadyGone: true };
|
|
459
562
|
}
|
|
460
563
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
@@ -463,6 +566,7 @@ export function archiveSession(yeaftDir, sessionId) {
|
|
|
463
566
|
const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
|
|
464
567
|
renameSync(srcDir, dstDir);
|
|
465
568
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
569
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
466
570
|
return { sessionId, archivedAs: dstDir, alreadyGone: false };
|
|
467
571
|
}
|
|
468
572
|
|
|
@@ -479,8 +583,9 @@ export function archiveSession(yeaftDir, sessionId) {
|
|
|
479
583
|
* delete cleans up legacy state too.
|
|
480
584
|
*/
|
|
481
585
|
export function deleteSession(yeaftDir, sessionId, options = {}) {
|
|
482
|
-
const
|
|
483
|
-
const
|
|
586
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
587
|
+
const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
|
|
588
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
484
589
|
const srcDir = join(root, sessionId);
|
|
485
590
|
const liveExists = existsSync(srcDir) && !!loadSessionMeta(srcDir);
|
|
486
591
|
|
|
@@ -520,6 +625,7 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
|
|
|
520
625
|
}
|
|
521
626
|
|
|
522
627
|
unregisterSessionWorkDir(yeaftDir, sessionId);
|
|
628
|
+
removeManifestSession(yeaftDir, sessionId);
|
|
523
629
|
return {
|
|
524
630
|
sessionId,
|
|
525
631
|
deleted: liveExists,
|
|
@@ -601,7 +707,8 @@ export function setSessionDefaultVp(yeaftDir, sessionId, vpId) {
|
|
|
601
707
|
}
|
|
602
708
|
|
|
603
709
|
export function requireSession(yeaftDir, sessionId) {
|
|
604
|
-
const
|
|
710
|
+
const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
|
|
711
|
+
const root = sessionsRoot(groupYeaftDir);
|
|
605
712
|
const dir = join(root, sessionId);
|
|
606
713
|
if (!existsSync(dir) || !loadSessionMeta(dir)) {
|
|
607
714
|
throw new SessionCrudError('not_found', sessionId);
|
|
@@ -611,14 +718,10 @@ export function requireSession(yeaftDir, sessionId) {
|
|
|
611
718
|
|
|
612
719
|
/** Convenience: snapshot all non-archived groups for WS broadcast. */
|
|
613
720
|
export function snapshotSessions(yeaftDir) {
|
|
721
|
+
ensureSessionManifestReady(yeaftDir);
|
|
614
722
|
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);
|
|
723
|
+
for (const row of listManifestSessions(yeaftDir)) {
|
|
724
|
+
byId.set(row.meta.id, row.meta);
|
|
622
725
|
}
|
|
623
726
|
// Attach per-group config overrides (v1: just `model`). Frontend can
|
|
624
727
|
// 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
|
+
}
|