@moxxy/core 0.3.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { EventLog } from '../events/log.js';
|
|
6
|
+
import type { Logger } from '../logger.js';
|
|
7
|
+
import { SessionPersistence, readIndex, restoreEvents, type SessionMeta } from './persistence.js';
|
|
8
|
+
|
|
9
|
+
interface CapturedLine {
|
|
10
|
+
readonly level: 'debug' | 'info' | 'warn' | 'error';
|
|
11
|
+
readonly msg: string;
|
|
12
|
+
readonly meta?: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function captureLogger(): { logger: Logger; lines: CapturedLine[] } {
|
|
16
|
+
const lines: CapturedLine[] = [];
|
|
17
|
+
const logger: Logger = {
|
|
18
|
+
debug: (msg, meta) => lines.push({ level: 'debug', msg, meta }),
|
|
19
|
+
info: (msg, meta) => lines.push({ level: 'info', msg, meta }),
|
|
20
|
+
warn: (msg, meta) => lines.push({ level: 'warn', msg, meta }),
|
|
21
|
+
error: (msg, meta) => lines.push({ level: 'error', msg, meta }),
|
|
22
|
+
child: () => logger,
|
|
23
|
+
};
|
|
24
|
+
return { logger, lines };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const tempDirs: string[] = [];
|
|
28
|
+
|
|
29
|
+
async function makeTempDir(): Promise<string> {
|
|
30
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'moxxy-sessions-'));
|
|
31
|
+
tempDirs.push(dir);
|
|
32
|
+
return dir;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
afterEach(async () => {
|
|
36
|
+
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function meta(id: string, eventCount = 0): SessionMeta {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
cwd: '/tmp/project',
|
|
43
|
+
startedAt: '2026-05-21T00:00:00.000Z',
|
|
44
|
+
lastActivity: '2026-05-21T00:00:00.000Z',
|
|
45
|
+
eventCount,
|
|
46
|
+
firstPrompt: eventCount > 0 ? 'hello' : null,
|
|
47
|
+
provider: null,
|
|
48
|
+
model: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('SessionPersistence', () => {
|
|
53
|
+
it('readIndex ignores rows whose event log file is missing', async () => {
|
|
54
|
+
const dir = await makeTempDir();
|
|
55
|
+
await fs.mkdir(dir, { recursive: true });
|
|
56
|
+
await fs.writeFile(path.join(dir, 'present.jsonl'), '', 'utf8');
|
|
57
|
+
await fs.writeFile(
|
|
58
|
+
path.join(dir, 'index.json'),
|
|
59
|
+
JSON.stringify([meta('missing'), meta('present')], null, 2),
|
|
60
|
+
'utf8',
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await expect(readIndex(dir)).resolves.toEqual([meta('present')]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('creates a resumable empty event log when a session is indexed before any events', async () => {
|
|
67
|
+
const dir = await makeTempDir();
|
|
68
|
+
const id = '01EMPTYSESSION000000000000';
|
|
69
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/project', dir });
|
|
70
|
+
const detach = persistence.attach(new EventLog());
|
|
71
|
+
|
|
72
|
+
await waitForFile(path.join(dir, `${id}.jsonl`));
|
|
73
|
+
await expect(restoreEvents(id, dir)).resolves.toEqual([]);
|
|
74
|
+
|
|
75
|
+
detach();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('writes a per-session sidecar that readIndex assembles', async () => {
|
|
79
|
+
const dir = await makeTempDir();
|
|
80
|
+
const id = '01SIDECAR00000000000000000';
|
|
81
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
|
|
82
|
+
const detach = persistence.attach(new EventLog());
|
|
83
|
+
await waitForFile(path.join(dir, `${id}.meta.json`));
|
|
84
|
+
const ids = (await readIndex(dir)).map((m) => m.id);
|
|
85
|
+
expect(ids).toContain(id);
|
|
86
|
+
detach();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('log.clear() truncates the JSONL so wiped history cannot resurrect on resume', async () => {
|
|
90
|
+
const dir = await makeTempDir();
|
|
91
|
+
const id = '01CLEARTRUNCATE00000000000';
|
|
92
|
+
const log = new EventLog();
|
|
93
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir });
|
|
94
|
+
const detach = persistence.attach(log);
|
|
95
|
+
|
|
96
|
+
await log.append({
|
|
97
|
+
type: 'user_prompt',
|
|
98
|
+
sessionId: id as never,
|
|
99
|
+
turnId: 't1' as never,
|
|
100
|
+
source: 'user',
|
|
101
|
+
text: 'wiped by /new',
|
|
102
|
+
});
|
|
103
|
+
await waitForCondition(async () => (await restoreEvents(id, dir)).length === 1);
|
|
104
|
+
|
|
105
|
+
// /new — the in-memory wipe must reach the sidecar.
|
|
106
|
+
log.clear();
|
|
107
|
+
await waitForCondition(async () => (await restoreEvents(id, dir)).length === 0);
|
|
108
|
+
|
|
109
|
+
// Post-reset events land in the fresh file from seq 0, so a later
|
|
110
|
+
// --resume restores exactly the new conversation (no duplicate seqs).
|
|
111
|
+
await log.append({
|
|
112
|
+
type: 'user_prompt',
|
|
113
|
+
sessionId: id as never,
|
|
114
|
+
turnId: 't2' as never,
|
|
115
|
+
source: 'user',
|
|
116
|
+
text: 'fresh start',
|
|
117
|
+
});
|
|
118
|
+
await waitForCondition(async () => (await restoreEvents(id, dir)).length === 1);
|
|
119
|
+
const restored = await restoreEvents(id, dir);
|
|
120
|
+
expect(restored).toHaveLength(1);
|
|
121
|
+
expect(restored[0]!.seq).toBe(0);
|
|
122
|
+
expect((restored[0] as { text?: string }).text).toBe('fresh start');
|
|
123
|
+
|
|
124
|
+
detach();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('warns once (not per event) on event-log write failure, recovers on success', async () => {
|
|
128
|
+
const dir = await makeTempDir();
|
|
129
|
+
const id = '01WRITEFAIL000000000000000';
|
|
130
|
+
// Make `<id>.jsonl` a DIRECTORY so fs.appendFile fails with EISDIR.
|
|
131
|
+
await fs.mkdir(path.join(dir, `${id}.jsonl`), { recursive: true });
|
|
132
|
+
|
|
133
|
+
const { logger, lines } = captureLogger();
|
|
134
|
+
const log = new EventLog();
|
|
135
|
+
const persistence = new SessionPersistence({ sessionId: id as never, cwd: '/tmp/p', dir, logger });
|
|
136
|
+
const detach = persistence.attach(log);
|
|
137
|
+
|
|
138
|
+
const prompt = (text: string) =>
|
|
139
|
+
log.append({ type: 'user_prompt', sessionId: id as never, turnId: 't1' as never, source: 'user', text });
|
|
140
|
+
|
|
141
|
+
await prompt('first failing write');
|
|
142
|
+
await prompt('second failing write');
|
|
143
|
+
await waitForCondition(() => Promise.resolve(persistence.degraded));
|
|
144
|
+
// Drain the queue so BOTH failing appends have actually been attempted (and
|
|
145
|
+
// failed) before we remove the obstruction below — otherwise a still-queued
|
|
146
|
+
// append could run after the rmdir and succeed, persisting an event the test
|
|
147
|
+
// expects to have been lost (a scheduler-timing-dependent flake).
|
|
148
|
+
await persistence.settleWrites();
|
|
149
|
+
// Both appends failed, but the structured warning fired exactly once.
|
|
150
|
+
await waitForCondition(() =>
|
|
151
|
+
Promise.resolve(lines.some((l) => l.level === 'warn' && l.msg.includes('write failed'))),
|
|
152
|
+
);
|
|
153
|
+
const failureWarns = () =>
|
|
154
|
+
lines.filter((l) => l.level === 'warn' && l.msg.includes('write failed'));
|
|
155
|
+
expect(failureWarns()).toHaveLength(1);
|
|
156
|
+
expect(failureWarns()[0]!.meta).toMatchObject({ path: path.join(dir, `${id}.jsonl`) });
|
|
157
|
+
expect(failureWarns()[0]!.meta?.error).toBeTruthy();
|
|
158
|
+
expect(persistence.degraded).toBe(true);
|
|
159
|
+
|
|
160
|
+
// Remove the obstruction — the next successful write clears the degraded
|
|
161
|
+
// latch (re-arming warn-once) and logs a recovery line.
|
|
162
|
+
await fs.rmdir(path.join(dir, `${id}.jsonl`));
|
|
163
|
+
await prompt('now it works');
|
|
164
|
+
await waitForCondition(() => Promise.resolve(!persistence.degraded));
|
|
165
|
+
expect(lines.some((l) => l.level === 'info' && l.msg.includes('recovered'))).toBe(true);
|
|
166
|
+
expect(failureWarns()).toHaveLength(1);
|
|
167
|
+
// The surviving event was minted at seq 2 (the first two appends were
|
|
168
|
+
// lost to the failing disk) — restore re-sequences it to 0.
|
|
169
|
+
const restored = await restoreEvents(id, dir, captureLogger().logger);
|
|
170
|
+
expect(restored.map((e) => (e as { text?: string }).text)).toEqual(['now it works']);
|
|
171
|
+
expect(restored[0]!.seq).toBe(0);
|
|
172
|
+
|
|
173
|
+
detach();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('restore re-sequences around a corrupt middle line and repairs the file on disk', async () => {
|
|
177
|
+
const dir = await makeTempDir();
|
|
178
|
+
const id = '01RESEQ0000000000000000000';
|
|
179
|
+
const mk = (seq: number, text: string) => ({
|
|
180
|
+
id: `e${seq}`,
|
|
181
|
+
seq,
|
|
182
|
+
ts: seq,
|
|
183
|
+
sessionId: id,
|
|
184
|
+
turnId: 't1',
|
|
185
|
+
source: 'user',
|
|
186
|
+
type: 'user_prompt',
|
|
187
|
+
text,
|
|
188
|
+
});
|
|
189
|
+
// seq 2's line was corrupted on disk; 3 and 4 are intact.
|
|
190
|
+
const lines = [
|
|
191
|
+
JSON.stringify(mk(0, 'zero')),
|
|
192
|
+
JSON.stringify(mk(1, 'one')),
|
|
193
|
+
'{this line was corrupted',
|
|
194
|
+
JSON.stringify(mk(3, 'three')),
|
|
195
|
+
JSON.stringify(mk(4, 'four')),
|
|
196
|
+
];
|
|
197
|
+
await fs.writeFile(path.join(dir, `${id}.jsonl`), lines.join('\n') + '\n', 'utf8');
|
|
198
|
+
|
|
199
|
+
const { logger, lines: logged } = captureLogger();
|
|
200
|
+
const restored = await restoreEvents(id, dir, logger);
|
|
201
|
+
|
|
202
|
+
// Contiguous 0..n-1, order + ids + payloads preserved.
|
|
203
|
+
expect(restored.map((e) => e.seq)).toEqual([0, 1, 2, 3]);
|
|
204
|
+
expect(restored.map((e) => e.id)).toEqual(['e0', 'e1', 'e3', 'e4']);
|
|
205
|
+
expect(restored.map((e) => (e as { text?: string }).text)).toEqual([
|
|
206
|
+
'zero',
|
|
207
|
+
'one',
|
|
208
|
+
'three',
|
|
209
|
+
'four',
|
|
210
|
+
]);
|
|
211
|
+
const gapWarn = logged.find((l) => l.level === 'warn');
|
|
212
|
+
expect(gapWarn?.meta).toMatchObject({ corruptLines: 1, resequencedEvents: 2 });
|
|
213
|
+
|
|
214
|
+
// Every restored event replays into a fresh mirror (ingest requires
|
|
215
|
+
// seq === length) — nothing after the gap is dropped.
|
|
216
|
+
const mirror = new EventLog();
|
|
217
|
+
for (const e of restored) mirror.ingest(e);
|
|
218
|
+
expect(mirror.length).toBe(4);
|
|
219
|
+
expect((mirror.at(3) as { text?: string } | undefined)?.text).toBe('four');
|
|
220
|
+
|
|
221
|
+
// The file was rewritten, so the NEXT restore is clean (no warning) and
|
|
222
|
+
// new appends (seq = length) line up with what's on disk.
|
|
223
|
+
const again = captureLogger();
|
|
224
|
+
const second = await restoreEvents(id, dir, again.logger);
|
|
225
|
+
expect(second.map((e) => e.seq)).toEqual([0, 1, 2, 3]);
|
|
226
|
+
expect(again.lines).toHaveLength(0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('two concurrent sessions both survive (no shared-index clobber)', async () => {
|
|
230
|
+
const dir = await makeTempDir();
|
|
231
|
+
const idA = '01AAAA00000000000000000001';
|
|
232
|
+
const idB = '01BBBB00000000000000000002';
|
|
233
|
+
const detachA = new SessionPersistence({ sessionId: idA as never, cwd: '/a', dir }).attach(
|
|
234
|
+
new EventLog(),
|
|
235
|
+
);
|
|
236
|
+
const detachB = new SessionPersistence({ sessionId: idB as never, cwd: '/b', dir }).attach(
|
|
237
|
+
new EventLog(),
|
|
238
|
+
);
|
|
239
|
+
await waitForFile(path.join(dir, `${idA}.meta.json`));
|
|
240
|
+
await waitForFile(path.join(dir, `${idB}.meta.json`));
|
|
241
|
+
const ids = (await readIndex(dir)).map((m) => m.id);
|
|
242
|
+
expect(ids).toContain(idA);
|
|
243
|
+
expect(ids).toContain(idB);
|
|
244
|
+
detachA();
|
|
245
|
+
detachB();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
async function waitForFile(file: string): Promise<void> {
|
|
250
|
+
const deadline = Date.now() + 1_000;
|
|
251
|
+
for (;;) {
|
|
252
|
+
try {
|
|
253
|
+
await fs.access(file);
|
|
254
|
+
return;
|
|
255
|
+
} catch {
|
|
256
|
+
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${file}`);
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Poll an async predicate until it holds (writes are queued + debounced). */
|
|
263
|
+
async function waitForCondition(predicate: () => Promise<boolean>): Promise<void> {
|
|
264
|
+
const deadline = Date.now() + 2_000;
|
|
265
|
+
for (;;) {
|
|
266
|
+
try {
|
|
267
|
+
if (await predicate()) return;
|
|
268
|
+
} catch {
|
|
269
|
+
// file may not exist yet — keep polling
|
|
270
|
+
}
|
|
271
|
+
if (Date.now() > deadline) throw new Error('Timed out waiting for condition');
|
|
272
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
273
|
+
}
|
|
274
|
+
}
|