@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.
Files changed (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/events/factory.d.ts +6 -0
  4. package/dist/events/factory.d.ts.map +1 -0
  5. package/dist/events/factory.js +14 -0
  6. package/dist/events/factory.js.map +1 -0
  7. package/dist/events/log.d.ts +92 -0
  8. package/dist/events/log.d.ts.map +1 -0
  9. package/dist/events/log.js +244 -0
  10. package/dist/events/log.js.map +1 -0
  11. package/dist/index.d.ts +40 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +16 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +27 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/permissions/engine.d.ts +94 -0
  20. package/dist/permissions/engine.d.ts.map +1 -0
  21. package/dist/permissions/engine.js +203 -0
  22. package/dist/permissions/engine.js.map +1 -0
  23. package/dist/permissions/resolvers.d.ts +8 -0
  24. package/dist/permissions/resolvers.d.ts.map +1 -0
  25. package/dist/permissions/resolvers.js +8 -0
  26. package/dist/permissions/resolvers.js.map +1 -0
  27. package/dist/plugins/discovery.d.ts +9 -0
  28. package/dist/plugins/discovery.d.ts.map +1 -0
  29. package/dist/plugins/discovery.js +112 -0
  30. package/dist/plugins/discovery.js.map +1 -0
  31. package/dist/plugins/host-options.d.ts +72 -0
  32. package/dist/plugins/host-options.d.ts.map +1 -0
  33. package/dist/plugins/host-options.js +2 -0
  34. package/dist/plugins/host-options.js.map +1 -0
  35. package/dist/plugins/host.d.ts +53 -0
  36. package/dist/plugins/host.d.ts.map +1 -0
  37. package/dist/plugins/host.js +269 -0
  38. package/dist/plugins/host.js.map +1 -0
  39. package/dist/plugins/lifecycle.d.ts +31 -0
  40. package/dist/plugins/lifecycle.d.ts.map +1 -0
  41. package/dist/plugins/lifecycle.js +100 -0
  42. package/dist/plugins/lifecycle.js.map +1 -0
  43. package/dist/plugins/loader.d.ts +7 -0
  44. package/dist/plugins/loader.d.ts.map +1 -0
  45. package/dist/plugins/loader.js +68 -0
  46. package/dist/plugins/loader.js.map +1 -0
  47. package/dist/plugins/package-requirements.d.ts +16 -0
  48. package/dist/plugins/package-requirements.d.ts.map +1 -0
  49. package/dist/plugins/package-requirements.js +52 -0
  50. package/dist/plugins/package-requirements.js.map +1 -0
  51. package/dist/plugins/registry-kinds.d.ts +58 -0
  52. package/dist/plugins/registry-kinds.d.ts.map +1 -0
  53. package/dist/plugins/registry-kinds.js +139 -0
  54. package/dist/plugins/registry-kinds.js.map +1 -0
  55. package/dist/plugins/toposort.d.ts +24 -0
  56. package/dist/plugins/toposort.d.ts.map +1 -0
  57. package/dist/plugins/toposort.js +67 -0
  58. package/dist/plugins/toposort.js.map +1 -0
  59. package/dist/preferences.d.ts +40 -0
  60. package/dist/preferences.d.ts.map +1 -0
  61. package/dist/preferences.js +58 -0
  62. package/dist/preferences.js.map +1 -0
  63. package/dist/registries/active-backend-registry.d.ts +65 -0
  64. package/dist/registries/active-backend-registry.d.ts.map +1 -0
  65. package/dist/registries/active-backend-registry.js +108 -0
  66. package/dist/registries/active-backend-registry.js.map +1 -0
  67. package/dist/registries/active-def-registry.d.ts +59 -0
  68. package/dist/registries/active-def-registry.d.ts.map +1 -0
  69. package/dist/registries/active-def-registry.js +63 -0
  70. package/dist/registries/active-def-registry.js.map +1 -0
  71. package/dist/registries/agents.d.ts +16 -0
  72. package/dist/registries/agents.d.ts.map +1 -0
  73. package/dist/registries/agents.js +17 -0
  74. package/dist/registries/agents.js.map +1 -0
  75. package/dist/registries/cache-strategies.d.ts +12 -0
  76. package/dist/registries/cache-strategies.d.ts.map +1 -0
  77. package/dist/registries/cache-strategies.js +13 -0
  78. package/dist/registries/cache-strategies.js.map +1 -0
  79. package/dist/registries/channels.d.ts +16 -0
  80. package/dist/registries/channels.d.ts.map +1 -0
  81. package/dist/registries/channels.js +35 -0
  82. package/dist/registries/channels.js.map +1 -0
  83. package/dist/registries/commands.d.ts +29 -0
  84. package/dist/registries/commands.d.ts.map +1 -0
  85. package/dist/registries/commands.js +72 -0
  86. package/dist/registries/commands.js.map +1 -0
  87. package/dist/registries/compactors.d.ts +12 -0
  88. package/dist/registries/compactors.d.ts.map +1 -0
  89. package/dist/registries/compactors.js +13 -0
  90. package/dist/registries/compactors.js.map +1 -0
  91. package/dist/registries/def-map-registry.d.ts +33 -0
  92. package/dist/registries/def-map-registry.d.ts.map +1 -0
  93. package/dist/registries/def-map-registry.js +36 -0
  94. package/dist/registries/def-map-registry.js.map +1 -0
  95. package/dist/registries/embedders.d.ts +16 -0
  96. package/dist/registries/embedders.d.ts.map +1 -0
  97. package/dist/registries/embedders.js +17 -0
  98. package/dist/registries/embedders.js.map +1 -0
  99. package/dist/registries/isolators.d.ts +21 -0
  100. package/dist/registries/isolators.d.ts.map +1 -0
  101. package/dist/registries/isolators.js +32 -0
  102. package/dist/registries/isolators.js.map +1 -0
  103. package/dist/registries/modes.d.ts +28 -0
  104. package/dist/registries/modes.d.ts.map +1 -0
  105. package/dist/registries/modes.js +81 -0
  106. package/dist/registries/modes.js.map +1 -0
  107. package/dist/registries/providers.d.ts +44 -0
  108. package/dist/registries/providers.d.ts.map +1 -0
  109. package/dist/registries/providers.js +95 -0
  110. package/dist/registries/providers.js.map +1 -0
  111. package/dist/registries/skills.d.ts +24 -0
  112. package/dist/registries/skills.d.ts.map +1 -0
  113. package/dist/registries/skills.js +81 -0
  114. package/dist/registries/skills.js.map +1 -0
  115. package/dist/registries/surfaces.d.ts +13 -0
  116. package/dist/registries/surfaces.d.ts.map +1 -0
  117. package/dist/registries/surfaces.js +14 -0
  118. package/dist/registries/surfaces.js.map +1 -0
  119. package/dist/registries/synthesizers.d.ts +26 -0
  120. package/dist/registries/synthesizers.d.ts.map +1 -0
  121. package/dist/registries/synthesizers.js +34 -0
  122. package/dist/registries/synthesizers.js.map +1 -0
  123. package/dist/registries/tools.d.ts +49 -0
  124. package/dist/registries/tools.d.ts.map +1 -0
  125. package/dist/registries/tools.js +98 -0
  126. package/dist/registries/tools.js.map +1 -0
  127. package/dist/registries/transcribers.d.ts +17 -0
  128. package/dist/registries/transcribers.d.ts.map +1 -0
  129. package/dist/registries/transcribers.js +18 -0
  130. package/dist/registries/transcribers.js.map +1 -0
  131. package/dist/registries/tunnel-providers.d.ts +12 -0
  132. package/dist/registries/tunnel-providers.d.ts.map +1 -0
  133. package/dist/registries/tunnel-providers.js +13 -0
  134. package/dist/registries/tunnel-providers.js.map +1 -0
  135. package/dist/registries/view-renderers.d.ts +13 -0
  136. package/dist/registries/view-renderers.d.ts.map +1 -0
  137. package/dist/registries/view-renderers.js +14 -0
  138. package/dist/registries/view-renderers.js.map +1 -0
  139. package/dist/registries/workflow-executors.d.ts +13 -0
  140. package/dist/registries/workflow-executors.d.ts.map +1 -0
  141. package/dist/registries/workflow-executors.js +14 -0
  142. package/dist/registries/workflow-executors.js.map +1 -0
  143. package/dist/requirements.d.ts +59 -0
  144. package/dist/requirements.d.ts.map +1 -0
  145. package/dist/requirements.js +149 -0
  146. package/dist/requirements.js.map +1 -0
  147. package/dist/run-turn.d.ts +6 -0
  148. package/dist/run-turn.d.ts.map +1 -0
  149. package/dist/run-turn.js +141 -0
  150. package/dist/run-turn.js.map +1 -0
  151. package/dist/session-runtime.d.ts +67 -0
  152. package/dist/session-runtime.d.ts.map +1 -0
  153. package/dist/session-runtime.js +2 -0
  154. package/dist/session-runtime.js.map +1 -0
  155. package/dist/session.d.ts +203 -0
  156. package/dist/session.d.ts.map +1 -0
  157. package/dist/session.js +399 -0
  158. package/dist/session.js.map +1 -0
  159. package/dist/sessions/persistence.d.ts +184 -0
  160. package/dist/sessions/persistence.d.ts.map +1 -0
  161. package/dist/sessions/persistence.js +442 -0
  162. package/dist/sessions/persistence.js.map +1 -0
  163. package/dist/setup-agent.d.ts +91 -0
  164. package/dist/setup-agent.d.ts.map +1 -0
  165. package/dist/setup-agent.js +138 -0
  166. package/dist/setup-agent.js.map +1 -0
  167. package/dist/skills/index.d.ts +4 -0
  168. package/dist/skills/index.d.ts.map +1 -0
  169. package/dist/skills/index.js +4 -0
  170. package/dist/skills/index.js.map +1 -0
  171. package/dist/skills/loader.d.ts +16 -0
  172. package/dist/skills/loader.d.ts.map +1 -0
  173. package/dist/skills/loader.js +99 -0
  174. package/dist/skills/loader.js.map +1 -0
  175. package/dist/skills/parse.d.ts +5 -0
  176. package/dist/skills/parse.d.ts.map +1 -0
  177. package/dist/skills/parse.js +8 -0
  178. package/dist/skills/parse.js.map +1 -0
  179. package/dist/skills/synthesize-draft.d.ts +8 -0
  180. package/dist/skills/synthesize-draft.d.ts.map +1 -0
  181. package/dist/skills/synthesize-draft.js +32 -0
  182. package/dist/skills/synthesize-draft.js.map +1 -0
  183. package/dist/skills/synthesize.d.ts +33 -0
  184. package/dist/skills/synthesize.d.ts.map +1 -0
  185. package/dist/skills/synthesize.js +241 -0
  186. package/dist/skills/synthesize.js.map +1 -0
  187. package/dist/subagents/events.d.ts +20 -0
  188. package/dist/subagents/events.d.ts.map +1 -0
  189. package/dist/subagents/events.js +146 -0
  190. package/dist/subagents/events.js.map +1 -0
  191. package/dist/subagents/registry.d.ts +40 -0
  192. package/dist/subagents/registry.d.ts.map +1 -0
  193. package/dist/subagents/registry.js +42 -0
  194. package/dist/subagents/registry.js.map +1 -0
  195. package/dist/subagents/run-child.d.ts +43 -0
  196. package/dist/subagents/run-child.d.ts.map +1 -0
  197. package/dist/subagents/run-child.js +276 -0
  198. package/dist/subagents/run-child.js.map +1 -0
  199. package/dist/subagents/spawn.d.ts +8 -0
  200. package/dist/subagents/spawn.d.ts.map +1 -0
  201. package/dist/subagents/spawn.js +8 -0
  202. package/dist/subagents/spawn.js.map +1 -0
  203. package/dist/subagents/tools.d.ts +4 -0
  204. package/dist/subagents/tools.d.ts.map +1 -0
  205. package/dist/subagents/tools.js +13 -0
  206. package/dist/subagents/tools.js.map +1 -0
  207. package/dist/subagents.d.ts +3 -0
  208. package/dist/subagents.d.ts.map +1 -0
  209. package/dist/subagents.js +3 -0
  210. package/dist/subagents.js.map +1 -0
  211. package/dist/surfaces/host.d.ts +53 -0
  212. package/dist/surfaces/host.d.ts.map +1 -0
  213. package/dist/surfaces/host.js +177 -0
  214. package/dist/surfaces/host.js.map +1 -0
  215. package/dist/tunnel/localhost.d.ts +7 -0
  216. package/dist/tunnel/localhost.d.ts.map +1 -0
  217. package/dist/tunnel/localhost.js +21 -0
  218. package/dist/tunnel/localhost.js.map +1 -0
  219. package/dist/usage-stats.d.ts +42 -0
  220. package/dist/usage-stats.d.ts.map +1 -0
  221. package/dist/usage-stats.js +83 -0
  222. package/dist/usage-stats.js.map +1 -0
  223. package/dist/view/default-renderer.d.ts +7 -0
  224. package/dist/view/default-renderer.d.ts.map +1 -0
  225. package/dist/view/default-renderer.js +14 -0
  226. package/dist/view/default-renderer.js.map +1 -0
  227. package/dist/view/parse.d.ts +7 -0
  228. package/dist/view/parse.d.ts.map +1 -0
  229. package/dist/view/parse.js +479 -0
  230. package/dist/view/parse.js.map +1 -0
  231. package/package.json +61 -0
  232. package/src/events/factory.ts +27 -0
  233. package/src/events/log.test.ts +470 -0
  234. package/src/events/log.ts +256 -0
  235. package/src/index.ts +102 -0
  236. package/src/logger.ts +46 -0
  237. package/src/permissions/engine.test.ts +206 -0
  238. package/src/permissions/engine.ts +247 -0
  239. package/src/permissions/resolvers.test.ts +40 -0
  240. package/src/permissions/resolvers.ts +17 -0
  241. package/src/plugins/discovery.test.ts +115 -0
  242. package/src/plugins/discovery.ts +124 -0
  243. package/src/plugins/host-options.ts +73 -0
  244. package/src/plugins/host-view-tunnel.test.ts +86 -0
  245. package/src/plugins/host.test.ts +434 -0
  246. package/src/plugins/host.ts +327 -0
  247. package/src/plugins/lifecycle.test.ts +187 -0
  248. package/src/plugins/lifecycle.ts +136 -0
  249. package/src/plugins/loader.ts +79 -0
  250. package/src/plugins/package-requirements.ts +50 -0
  251. package/src/plugins/registry-kinds.test.ts +162 -0
  252. package/src/plugins/registry-kinds.ts +210 -0
  253. package/src/plugins/toposort.test.ts +66 -0
  254. package/src/plugins/toposort.ts +69 -0
  255. package/src/preferences.test.ts +105 -0
  256. package/src/preferences.ts +84 -0
  257. package/src/registries/active-backend-registry.ts +142 -0
  258. package/src/registries/active-def-registry.test.ts +103 -0
  259. package/src/registries/active-def-registry.ts +97 -0
  260. package/src/registries/agents.ts +18 -0
  261. package/src/registries/cache-strategies.ts +14 -0
  262. package/src/registries/channels.ts +42 -0
  263. package/src/registries/commands.test.ts +71 -0
  264. package/src/registries/commands.ts +78 -0
  265. package/src/registries/compactors.ts +14 -0
  266. package/src/registries/def-map-registry.test.ts +67 -0
  267. package/src/registries/def-map-registry.ts +59 -0
  268. package/src/registries/embedders.test.ts +54 -0
  269. package/src/registries/embedders.ts +18 -0
  270. package/src/registries/isolators.test.ts +43 -0
  271. package/src/registries/isolators.ts +38 -0
  272. package/src/registries/modes.test.ts +66 -0
  273. package/src/registries/modes.ts +80 -0
  274. package/src/registries/providers.test.ts +50 -0
  275. package/src/registries/providers.ts +97 -0
  276. package/src/registries/semantics.test.ts +117 -0
  277. package/src/registries/skills.ts +87 -0
  278. package/src/registries/surfaces.ts +18 -0
  279. package/src/registries/synthesizers.test.ts +63 -0
  280. package/src/registries/synthesizers.ts +36 -0
  281. package/src/registries/tools.test.ts +142 -0
  282. package/src/registries/tools.ts +145 -0
  283. package/src/registries/transcribers.test.ts +70 -0
  284. package/src/registries/transcribers.ts +19 -0
  285. package/src/registries/tunnel-providers.test.ts +30 -0
  286. package/src/registries/tunnel-providers.ts +14 -0
  287. package/src/registries/view-renderers.test.ts +68 -0
  288. package/src/registries/view-renderers.ts +15 -0
  289. package/src/registries/workflow-executors.test.ts +53 -0
  290. package/src/registries/workflow-executors.ts +15 -0
  291. package/src/requirements.targetinfo.test.ts +70 -0
  292. package/src/requirements.test.ts +111 -0
  293. package/src/requirements.ts +262 -0
  294. package/src/run-turn.test.ts +178 -0
  295. package/src/run-turn.ts +156 -0
  296. package/src/session-runtime.ts +75 -0
  297. package/src/session.test.ts +216 -0
  298. package/src/session.ts +491 -0
  299. package/src/sessions/persistence.flush.test.ts +100 -0
  300. package/src/sessions/persistence.ready.test.ts +91 -0
  301. package/src/sessions/persistence.test.ts +274 -0
  302. package/src/sessions/persistence.ts +498 -0
  303. package/src/setup-agent.test.ts +72 -0
  304. package/src/setup-agent.ts +189 -0
  305. package/src/skills/index.ts +14 -0
  306. package/src/skills/loader.test.ts +102 -0
  307. package/src/skills/loader.ts +114 -0
  308. package/src/skills/parse.test.ts +59 -0
  309. package/src/skills/parse.ts +11 -0
  310. package/src/skills/synthesize-draft.test.ts +79 -0
  311. package/src/skills/synthesize-draft.ts +46 -0
  312. package/src/skills/synthesize.test.ts +170 -0
  313. package/src/skills/synthesize.ts +304 -0
  314. package/src/subagents/events.test.ts +56 -0
  315. package/src/subagents/events.ts +194 -0
  316. package/src/subagents/registry.ts +62 -0
  317. package/src/subagents/run-child.test.ts +185 -0
  318. package/src/subagents/run-child.ts +425 -0
  319. package/src/subagents/spawn.ts +12 -0
  320. package/src/subagents/tools.ts +19 -0
  321. package/src/subagents.ts +2 -0
  322. package/src/surfaces/host.test.ts +330 -0
  323. package/src/surfaces/host.ts +192 -0
  324. package/src/tunnel/localhost.test.ts +22 -0
  325. package/src/tunnel/localhost.ts +21 -0
  326. package/src/usage-stats.test.ts +77 -0
  327. package/src/usage-stats.ts +119 -0
  328. package/src/view/default-renderer.test.ts +29 -0
  329. package/src/view/default-renderer.ts +14 -0
  330. package/src/view/parse-extended.test.ts +255 -0
  331. package/src/view/parse.test.ts +232 -0
  332. 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
+ }