@moxxy/core 0.5.3 → 0.6.0
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +4 -1
- package/dist/run-turn.js.map +1 -1
- package/dist/sessions/persistence.d.ts +94 -10
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +356 -70
- package/dist/sessions/persistence.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +6 -0
- package/src/run-turn.test.ts +46 -0
- package/src/run-turn.ts +4 -1
- package/src/sessions/persistence.flush.test.ts +1 -1
- package/src/sessions/persistence.ready.test.ts +1 -1
- package/src/sessions/persistence.test.ts +312 -6
- package/src/sessions/persistence.ts +412 -67
package/src/index.ts
CHANGED
|
@@ -68,7 +68,13 @@ export {
|
|
|
68
68
|
readEventPage as readSessionEventPage,
|
|
69
69
|
pageEvents,
|
|
70
70
|
deleteSession,
|
|
71
|
+
listSessionMetas,
|
|
72
|
+
setSessionTitle,
|
|
73
|
+
setSessionGroup,
|
|
74
|
+
seedSessionMeta,
|
|
75
|
+
SESSION_META_VERSION,
|
|
71
76
|
type SessionMeta,
|
|
77
|
+
type SessionSource,
|
|
72
78
|
type SessionPersistenceOpts,
|
|
73
79
|
type EventPage,
|
|
74
80
|
} from './sessions/persistence.js';
|
package/src/run-turn.test.ts
CHANGED
|
@@ -59,6 +59,52 @@ function buildSession(): Session {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
describe('runTurn turnId filtering', () => {
|
|
62
|
+
it('continues the last resolved session model when no model override is supplied', async () => {
|
|
63
|
+
const models = [{ id: 'noop-default' }, { id: 'noop-sticky' }];
|
|
64
|
+
const session = new Session({ cwd: '/tmp', silent: true });
|
|
65
|
+
session.pluginHost.registerStatic(
|
|
66
|
+
definePlugin({
|
|
67
|
+
name: 'test-sticky-model',
|
|
68
|
+
version: '0.0.0',
|
|
69
|
+
providers: [
|
|
70
|
+
defineProvider({
|
|
71
|
+
name: 'noop',
|
|
72
|
+
models,
|
|
73
|
+
createClient: () => ({
|
|
74
|
+
name: 'noop',
|
|
75
|
+
models,
|
|
76
|
+
stream: async function* () {},
|
|
77
|
+
countTokens: async () => 0,
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
],
|
|
81
|
+
modes: [
|
|
82
|
+
defineMode({
|
|
83
|
+
name: 'model-echo',
|
|
84
|
+
run: async function* (ctx: ModeContext): AsyncIterable<MoxxyEvent> {
|
|
85
|
+
await ctx.emit({
|
|
86
|
+
type: 'assistant_message',
|
|
87
|
+
sessionId: ctx.sessionId,
|
|
88
|
+
turnId: ctx.turnId,
|
|
89
|
+
source: 'assistant',
|
|
90
|
+
text: ctx.model,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
],
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
session.providers.setActive('noop');
|
|
98
|
+
session.modes.setActive('model-echo');
|
|
99
|
+
session.lastResolvedModel = 'noop-sticky';
|
|
100
|
+
|
|
101
|
+
const events = await collectTurn(session, 'continue');
|
|
102
|
+
|
|
103
|
+
expect(events.find((e) => e.type === 'assistant_message')).toMatchObject({
|
|
104
|
+
text: 'noop-sticky',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
62
108
|
it('a single turn surfaces all of its own events', async () => {
|
|
63
109
|
const session = buildSession();
|
|
64
110
|
const events = await collectTurn(session, 'hi');
|
package/src/run-turn.ts
CHANGED
|
@@ -65,7 +65,10 @@ export async function* runTurn(
|
|
|
65
65
|
let model: string;
|
|
66
66
|
try {
|
|
67
67
|
provider = session.providers.getActive();
|
|
68
|
-
|
|
68
|
+
// Sticky model: prefer the explicit per-turn model, then the session's
|
|
69
|
+
// last-resolved model (so a conversation keeps the model it was using
|
|
70
|
+
// across turns), then the active provider's default.
|
|
71
|
+
const resolvedModel = opts.model ?? session.lastResolvedModel ?? provider.models[0]?.id;
|
|
69
72
|
if (!resolvedModel) {
|
|
70
73
|
throw new Error(
|
|
71
74
|
`Active provider '${provider.name}' has no models configured`,
|
|
@@ -18,7 +18,7 @@ afterEach(async () => {
|
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
async function readMeta(dir: string, id: string): Promise<SessionMeta> {
|
|
21
|
-
const raw = await fs.readFile(path.join(dir, `${id}.
|
|
21
|
+
const raw = await fs.readFile(path.join(dir, `${id}.json`), 'utf8');
|
|
22
22
|
return JSON.parse(raw) as SessionMeta;
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -53,7 +53,7 @@ describe('SessionPersistence one-time setup (no per-flush open/close)', () => {
|
|
|
53
53
|
expect(openSpy).toHaveBeenCalledTimes(1);
|
|
54
54
|
|
|
55
55
|
// Sidecar still written correctly.
|
|
56
|
-
const raw = await fs.readFile(path.join(dir, `${id}.
|
|
56
|
+
const raw = await fs.readFile(path.join(dir, `${id}.json`), 'utf8');
|
|
57
57
|
const meta = JSON.parse(raw) as { eventCount: number; firstPrompt: string | null };
|
|
58
58
|
expect(meta.eventCount).toBe(5);
|
|
59
59
|
expect(meta.firstPrompt).toBe('msg 0');
|
|
@@ -6,6 +6,7 @@ import { EventLog } from '../events/log.js';
|
|
|
6
6
|
import type { Logger } from '../logger.js';
|
|
7
7
|
import {
|
|
8
8
|
SessionPersistence,
|
|
9
|
+
defaultSessionsDir,
|
|
9
10
|
deleteSession,
|
|
10
11
|
readEventPage,
|
|
11
12
|
readIndex,
|
|
@@ -57,17 +58,194 @@ function meta(id: string, eventCount = 0): SessionMeta {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
describe('SessionPersistence', () => {
|
|
61
|
+
it('honors MOXXY_HOME for the default sessions directory', async () => {
|
|
62
|
+
const original = process.env.MOXXY_HOME;
|
|
63
|
+
const home = await makeTempDir();
|
|
64
|
+
process.env.MOXXY_HOME = home;
|
|
65
|
+
try {
|
|
66
|
+
expect(defaultSessionsDir()).toBe(path.join(home, 'sessions'));
|
|
67
|
+
} finally {
|
|
68
|
+
if (original === undefined) delete process.env.MOXXY_HOME;
|
|
69
|
+
else process.env.MOXXY_HOME = original;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
60
73
|
it('readIndex ignores rows whose event log file is missing', async () => {
|
|
61
74
|
const dir = await makeTempDir();
|
|
62
75
|
await fs.mkdir(dir, { recursive: true });
|
|
76
|
+
// `present` has a metadata file AND an event log; `missing` has only the
|
|
77
|
+
// metadata file. readIndex drops the one whose `.jsonl` is gone.
|
|
63
78
|
await fs.writeFile(path.join(dir, 'present.jsonl'), '', 'utf8');
|
|
79
|
+
await fs.writeFile(path.join(dir, 'present.json'), JSON.stringify(meta('present')), 'utf8');
|
|
80
|
+
await fs.writeFile(path.join(dir, 'missing.json'), JSON.stringify(meta('missing')), 'utf8');
|
|
81
|
+
|
|
82
|
+
await expect(readIndex(dir)).resolves.toEqual([meta('present')]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('readIndex recovers a missing firstPrompt from the event log', async () => {
|
|
86
|
+
const dir = await makeTempDir();
|
|
87
|
+
await fs.mkdir(dir, { recursive: true });
|
|
88
|
+
const id = '01HYDRATEPROMPT0000000000';
|
|
64
89
|
await fs.writeFile(
|
|
65
|
-
path.join(dir,
|
|
66
|
-
JSON.stringify(
|
|
90
|
+
path.join(dir, `${id}.json`),
|
|
91
|
+
JSON.stringify({ ...meta(id, 3), firstPrompt: null }, null, 2),
|
|
92
|
+
'utf8',
|
|
93
|
+
);
|
|
94
|
+
await fs.writeFile(
|
|
95
|
+
path.join(dir, `${id}.jsonl`),
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
id: 'e1',
|
|
98
|
+
seq: 0,
|
|
99
|
+
ts: 1,
|
|
100
|
+
sessionId: id,
|
|
101
|
+
turnId: 't1',
|
|
102
|
+
source: 'user',
|
|
103
|
+
type: 'user_prompt',
|
|
104
|
+
text: 'restored from log',
|
|
105
|
+
}) + '\n',
|
|
67
106
|
'utf8',
|
|
68
107
|
);
|
|
69
108
|
|
|
70
|
-
await
|
|
109
|
+
const [restored] = await readIndex(dir);
|
|
110
|
+
|
|
111
|
+
expect(restored?.firstPrompt).toBe('restored from log');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('readIndex refreshes provider and model from the latest provider event', async () => {
|
|
115
|
+
const dir = await makeTempDir();
|
|
116
|
+
await fs.mkdir(dir, { recursive: true });
|
|
117
|
+
const id = '01HYDRATEPROVIDER00000000';
|
|
118
|
+
await fs.writeFile(
|
|
119
|
+
path.join(dir, `${id}.json`),
|
|
120
|
+
JSON.stringify({ ...meta(id, 2), provider: 'old-provider', model: 'old-model' }, null, 2),
|
|
121
|
+
'utf8',
|
|
122
|
+
);
|
|
123
|
+
await fs.writeFile(
|
|
124
|
+
path.join(dir, `${id}.jsonl`),
|
|
125
|
+
[
|
|
126
|
+
JSON.stringify({
|
|
127
|
+
id: 'e1',
|
|
128
|
+
seq: 0,
|
|
129
|
+
ts: 1,
|
|
130
|
+
sessionId: id,
|
|
131
|
+
turnId: 't1',
|
|
132
|
+
source: 'system',
|
|
133
|
+
type: 'provider_request',
|
|
134
|
+
provider: 'first-provider',
|
|
135
|
+
model: 'first-model',
|
|
136
|
+
}),
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
id: 'e2',
|
|
139
|
+
seq: 1,
|
|
140
|
+
ts: 2,
|
|
141
|
+
sessionId: id,
|
|
142
|
+
turnId: 't1',
|
|
143
|
+
source: 'system',
|
|
144
|
+
type: 'provider_response',
|
|
145
|
+
provider: 'session-provider',
|
|
146
|
+
model: 'session-model',
|
|
147
|
+
inputTokens: 1,
|
|
148
|
+
outputTokens: 1,
|
|
149
|
+
}),
|
|
150
|
+
].join('\n') + '\n',
|
|
151
|
+
'utf8',
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const [restored] = await readIndex(dir);
|
|
155
|
+
|
|
156
|
+
expect(restored).toMatchObject({
|
|
157
|
+
provider: 'session-provider',
|
|
158
|
+
model: 'session-model',
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('readIndex preserves a sidecar firstPrompt when the event log is still empty', async () => {
|
|
163
|
+
const dir = await makeTempDir();
|
|
164
|
+
await fs.mkdir(dir, { recursive: true });
|
|
165
|
+
const id = '01SIDECARPROMPT0000000000';
|
|
166
|
+
await fs.writeFile(
|
|
167
|
+
path.join(dir, `${id}.json`),
|
|
168
|
+
JSON.stringify({ ...meta(id, 2), firstPrompt: 'sidecar title', eventCount: 2 }, null, 2),
|
|
169
|
+
'utf8',
|
|
170
|
+
);
|
|
171
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), '', 'utf8');
|
|
172
|
+
|
|
173
|
+
const [restored] = await readIndex(dir);
|
|
174
|
+
|
|
175
|
+
expect(restored?.firstPrompt).toBe('sidecar title');
|
|
176
|
+
expect(restored?.eventCount).toBe(2);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('readIndex uses only matching-session prompts when hydrating titles', async () => {
|
|
180
|
+
const dir = await makeTempDir();
|
|
181
|
+
await fs.mkdir(dir, { recursive: true });
|
|
182
|
+
const id = '01MATCHPROMPT000000000000';
|
|
183
|
+
await fs.writeFile(
|
|
184
|
+
path.join(dir, `${id}.json`),
|
|
185
|
+
JSON.stringify({ ...meta(id, 2), firstPrompt: 'foreign title', eventCount: 2 }, null, 2),
|
|
186
|
+
'utf8',
|
|
187
|
+
);
|
|
188
|
+
await fs.writeFile(
|
|
189
|
+
path.join(dir, `${id}.jsonl`),
|
|
190
|
+
[
|
|
191
|
+
{
|
|
192
|
+
id: 'foreign',
|
|
193
|
+
seq: 0,
|
|
194
|
+
ts: 1,
|
|
195
|
+
sessionId: 'other-session',
|
|
196
|
+
turnId: 't1',
|
|
197
|
+
source: 'user',
|
|
198
|
+
type: 'user_prompt',
|
|
199
|
+
text: 'foreign title',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
id: 'matching',
|
|
203
|
+
seq: 1,
|
|
204
|
+
ts: 2,
|
|
205
|
+
sessionId: id,
|
|
206
|
+
turnId: 't2',
|
|
207
|
+
source: 'user',
|
|
208
|
+
type: 'user_prompt',
|
|
209
|
+
text: 'real matching title',
|
|
210
|
+
},
|
|
211
|
+
].map((event) => JSON.stringify(event)).join('\n') + '\n',
|
|
212
|
+
'utf8',
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const [restored] = await readIndex(dir);
|
|
216
|
+
|
|
217
|
+
expect(restored?.firstPrompt).toBe('real matching title');
|
|
218
|
+
expect(restored?.eventCount).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('readIndex does not hydrate a visible title from a foreign-only log', async () => {
|
|
222
|
+
const dir = await makeTempDir();
|
|
223
|
+
await fs.mkdir(dir, { recursive: true });
|
|
224
|
+
const id = '01FOREIGNONLY00000000000';
|
|
225
|
+
await fs.writeFile(
|
|
226
|
+
path.join(dir, `${id}.json`),
|
|
227
|
+
JSON.stringify({ ...meta(id, 1), firstPrompt: 'foreign prompt', eventCount: 1 }, null, 2),
|
|
228
|
+
'utf8',
|
|
229
|
+
);
|
|
230
|
+
await fs.writeFile(
|
|
231
|
+
path.join(dir, `${id}.jsonl`),
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
id: 'foreign',
|
|
234
|
+
seq: 0,
|
|
235
|
+
ts: 1,
|
|
236
|
+
sessionId: 'other-session',
|
|
237
|
+
turnId: 't1',
|
|
238
|
+
source: 'user',
|
|
239
|
+
type: 'user_prompt',
|
|
240
|
+
text: 'foreign prompt',
|
|
241
|
+
}) + '\n',
|
|
242
|
+
'utf8',
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const [restored] = await readIndex(dir);
|
|
246
|
+
|
|
247
|
+
expect(restored?.firstPrompt).toBeNull();
|
|
248
|
+
expect(restored?.eventCount).toBe(0);
|
|
71
249
|
});
|
|
72
250
|
|
|
73
251
|
it('creates a resumable empty event log when a session is indexed before any events', async () => {
|
|
@@ -87,7 +265,7 @@ describe('SessionPersistence', () => {
|
|
|
87
265
|
const id = '01SIDECAR00000000000000000';
|
|
88
266
|
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
|
|
89
267
|
const detach = persistence.attach(new EventLog());
|
|
90
|
-
await waitForFile(path.join(dir, `${id}.
|
|
268
|
+
await waitForFile(path.join(dir, `${id}.json`));
|
|
91
269
|
const ids = (await readIndex(dir)).map((m) => m.id);
|
|
92
270
|
expect(ids).toContain(id);
|
|
93
271
|
detach();
|
|
@@ -131,6 +309,66 @@ describe('SessionPersistence', () => {
|
|
|
131
309
|
detach();
|
|
132
310
|
});
|
|
133
311
|
|
|
312
|
+
it('does not persist foreign-session events into the current session log', async () => {
|
|
313
|
+
const dir = await makeTempDir();
|
|
314
|
+
const id = '01CURRENTSESSION0000000000';
|
|
315
|
+
const log = new EventLog();
|
|
316
|
+
const { logger, lines } = captureLogger();
|
|
317
|
+
const persistence = new SessionPersistence({
|
|
318
|
+
sessionId: id as never,
|
|
319
|
+
cwd: '/tmp/p',
|
|
320
|
+
dir,
|
|
321
|
+
logger,
|
|
322
|
+
});
|
|
323
|
+
const detach = persistence.attach(log);
|
|
324
|
+
|
|
325
|
+
await log.append({
|
|
326
|
+
type: 'user_prompt',
|
|
327
|
+
sessionId: 'other-session' as never,
|
|
328
|
+
turnId: 't1' as never,
|
|
329
|
+
source: 'user',
|
|
330
|
+
text: 'should not persist',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await waitForCondition(async () =>
|
|
334
|
+
lines.some((line) => line.level === 'warn' && line.msg.includes('foreign-session')),
|
|
335
|
+
);
|
|
336
|
+
await waitForCondition(async () => (await readIndex(dir))[0]?.id === id);
|
|
337
|
+
|
|
338
|
+
expect(await restoreEvents(id, dir)).toEqual([]);
|
|
339
|
+
expect((await readIndex(dir))[0]).toMatchObject({ eventCount: 0, firstPrompt: null });
|
|
340
|
+
|
|
341
|
+
detach();
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('normalizes legacy in-memory events without sessionId to the current session before persisting', async () => {
|
|
345
|
+
const dir = await makeTempDir();
|
|
346
|
+
const id = '01LEGACYNOSESSION00000000';
|
|
347
|
+
const log = new EventLog();
|
|
348
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
|
|
349
|
+
const detach = persistence.attach(log);
|
|
350
|
+
|
|
351
|
+
await log.append({
|
|
352
|
+
type: 'user_prompt',
|
|
353
|
+
turnId: 't1' as never,
|
|
354
|
+
source: 'user',
|
|
355
|
+
text: 'legacy prompt without session id',
|
|
356
|
+
} as never);
|
|
357
|
+
|
|
358
|
+
await waitForCondition(async () => (await restoreEvents(id, dir)).length === 1);
|
|
359
|
+
const [event] = await restoreEvents(id, dir);
|
|
360
|
+
await waitForCondition(async () => (await readIndex(dir))[0]?.eventCount === 1);
|
|
361
|
+
|
|
362
|
+
expect(event?.sessionId).toBe(id);
|
|
363
|
+
expect((await readIndex(dir))[0]).toMatchObject({
|
|
364
|
+
id,
|
|
365
|
+
eventCount: 1,
|
|
366
|
+
firstPrompt: 'legacy prompt without session id',
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
detach();
|
|
370
|
+
});
|
|
371
|
+
|
|
134
372
|
it('warns once (not per event) on event-log write failure, recovers on success', async () => {
|
|
135
373
|
const dir = await makeTempDir();
|
|
136
374
|
const id = '01WRITEFAIL000000000000000';
|
|
@@ -233,6 +471,74 @@ describe('SessionPersistence', () => {
|
|
|
233
471
|
expect(again.lines).toHaveLength(0);
|
|
234
472
|
});
|
|
235
473
|
|
|
474
|
+
it('restore removes foreign-session events, creates a backup, and re-sequences survivors', async () => {
|
|
475
|
+
const dir = await makeTempDir();
|
|
476
|
+
const id = '01RESTOREFILTER000000000';
|
|
477
|
+
await fs.mkdir(dir, { recursive: true });
|
|
478
|
+
const logPath = path.join(dir, `${id}.jsonl`);
|
|
479
|
+
const events = [
|
|
480
|
+
{
|
|
481
|
+
id: 'foreign',
|
|
482
|
+
seq: 0,
|
|
483
|
+
ts: 1,
|
|
484
|
+
sessionId: 'other-session',
|
|
485
|
+
turnId: 't1',
|
|
486
|
+
source: 'user',
|
|
487
|
+
type: 'user_prompt',
|
|
488
|
+
text: 'foreign prompt',
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
id: 'matching',
|
|
492
|
+
seq: 5,
|
|
493
|
+
ts: 2,
|
|
494
|
+
sessionId: id,
|
|
495
|
+
turnId: 't2',
|
|
496
|
+
source: 'user',
|
|
497
|
+
type: 'user_prompt',
|
|
498
|
+
text: 'matching prompt',
|
|
499
|
+
},
|
|
500
|
+
];
|
|
501
|
+
await fs.writeFile(logPath, events.map((event) => JSON.stringify(event)).join('\n') + '\n', 'utf8');
|
|
502
|
+
|
|
503
|
+
const { logger, lines } = captureLogger();
|
|
504
|
+
const restored = await restoreEvents(id, dir, logger);
|
|
505
|
+
|
|
506
|
+
expect(restored.map((event) => event.id)).toEqual(['matching']);
|
|
507
|
+
expect(restored.map((event) => event.seq)).toEqual([0]);
|
|
508
|
+
expect(lines.some((line) => line.level === 'warn' && line.msg.includes('foreign-session'))).toBe(true);
|
|
509
|
+
await expect(fs.access(`${logPath}.foreign-session.bak`)).resolves.toBeUndefined();
|
|
510
|
+
const repaired = (await fs.readFile(logPath, 'utf8')).trim().split('\n').map((line) => JSON.parse(line));
|
|
511
|
+
expect(repaired.map((event) => event.id)).toEqual(['matching']);
|
|
512
|
+
expect(repaired.map((event) => event.seq)).toEqual([0]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('restore rewrites a foreign-only log to an empty session with a backup', async () => {
|
|
516
|
+
const dir = await makeTempDir();
|
|
517
|
+
const id = '01RESTOREEMPTY0000000000';
|
|
518
|
+
await fs.mkdir(dir, { recursive: true });
|
|
519
|
+
const logPath = path.join(dir, `${id}.jsonl`);
|
|
520
|
+
await fs.writeFile(
|
|
521
|
+
logPath,
|
|
522
|
+
JSON.stringify({
|
|
523
|
+
id: 'foreign',
|
|
524
|
+
seq: 0,
|
|
525
|
+
ts: 1,
|
|
526
|
+
sessionId: 'other-session',
|
|
527
|
+
turnId: 't1',
|
|
528
|
+
source: 'user',
|
|
529
|
+
type: 'user_prompt',
|
|
530
|
+
text: 'foreign prompt',
|
|
531
|
+
}) + '\n',
|
|
532
|
+
'utf8',
|
|
533
|
+
);
|
|
534
|
+
|
|
535
|
+
const restored = await restoreEvents(id, dir, captureLogger().logger);
|
|
536
|
+
|
|
537
|
+
expect(restored).toEqual([]);
|
|
538
|
+
expect(await fs.readFile(logPath, 'utf8')).toBe('');
|
|
539
|
+
await expect(fs.access(`${logPath}.foreign-session.bak`)).resolves.toBeUndefined();
|
|
540
|
+
});
|
|
541
|
+
|
|
236
542
|
it('rejects a path-traversal session id before touching the filesystem', async () => {
|
|
237
543
|
const dir = await makeTempDir();
|
|
238
544
|
// A secret file OUTSIDE the sessions dir that a traversal id would target.
|
|
@@ -328,8 +634,8 @@ describe('SessionPersistence', () => {
|
|
|
328
634
|
const detachB = new SessionPersistence({ sessionId: idB as never, cwd: '/b', dir }).attach(
|
|
329
635
|
new EventLog(),
|
|
330
636
|
);
|
|
331
|
-
await waitForFile(path.join(dir, `${idA}.
|
|
332
|
-
await waitForFile(path.join(dir, `${idB}.
|
|
637
|
+
await waitForFile(path.join(dir, `${idA}.json`));
|
|
638
|
+
await waitForFile(path.join(dir, `${idB}.json`));
|
|
333
639
|
const ids = (await readIndex(dir)).map((m) => m.id);
|
|
334
640
|
expect(ids).toContain(idA);
|
|
335
641
|
expect(ids).toContain(idB);
|