@namzu/sdk 0.2.0 → 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 (297) hide show
  1. package/CHANGELOG.md +53 -2
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +3 -2
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +5 -2
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/bridge/a2a/index.d.ts +1 -1
  9. package/dist/bridge/a2a/index.d.ts.map +1 -1
  10. package/dist/bridge/a2a/index.js +1 -1
  11. package/dist/bridge/a2a/index.js.map +1 -1
  12. package/dist/bridge/a2a/message.d.ts +0 -2
  13. package/dist/bridge/a2a/message.d.ts.map +1 -1
  14. package/dist/bridge/a2a/message.js +0 -26
  15. package/dist/bridge/a2a/message.js.map +1 -1
  16. package/dist/bridge/a2a/task.d.ts +4 -3
  17. package/dist/bridge/a2a/task.d.ts.map +1 -1
  18. package/dist/bridge/a2a/task.js +4 -4
  19. package/dist/bridge/a2a/task.js.map +1 -1
  20. package/dist/contracts/api.d.ts +6 -38
  21. package/dist/contracts/api.d.ts.map +1 -1
  22. package/dist/contracts/ids.d.ts +1 -1
  23. package/dist/contracts/ids.d.ts.map +1 -1
  24. package/dist/contracts/index.d.ts +3 -5
  25. package/dist/contracts/index.d.ts.map +1 -1
  26. package/dist/contracts/index.js +1 -1
  27. package/dist/contracts/index.js.map +1 -1
  28. package/dist/contracts/schemas.d.ts +1 -31
  29. package/dist/contracts/schemas.d.ts.map +1 -1
  30. package/dist/contracts/schemas.js +1 -7
  31. package/dist/contracts/schemas.js.map +1 -1
  32. package/dist/index.d.ts +2 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2 -3
  35. package/dist/index.js.map +1 -1
  36. package/dist/manager/agent/__tests__/lifecycle.test.js +27 -13
  37. package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
  38. package/dist/manager/agent/lifecycle.d.ts +9 -0
  39. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  40. package/dist/manager/agent/lifecycle.js +93 -31
  41. package/dist/manager/agent/lifecycle.js.map +1 -1
  42. package/dist/manager/index.d.ts +2 -0
  43. package/dist/manager/index.d.ts.map +1 -1
  44. package/dist/manager/index.js +1 -0
  45. package/dist/manager/index.js.map +1 -1
  46. package/dist/manager/run/persistence.d.ts +3 -1
  47. package/dist/manager/run/persistence.d.ts.map +1 -1
  48. package/dist/manager/run/persistence.js +5 -0
  49. package/dist/manager/run/persistence.js.map +1 -1
  50. package/dist/manager/thread/__tests__/lifecycle.test.d.ts +2 -0
  51. package/dist/manager/thread/__tests__/lifecycle.test.d.ts.map +1 -0
  52. package/dist/manager/thread/__tests__/lifecycle.test.js +216 -0
  53. package/dist/manager/thread/__tests__/lifecycle.test.js.map +1 -0
  54. package/dist/manager/thread/lifecycle.d.ts +105 -0
  55. package/dist/manager/thread/lifecycle.d.ts.map +1 -0
  56. package/dist/manager/thread/lifecycle.js +186 -0
  57. package/dist/manager/thread/lifecycle.js.map +1 -0
  58. package/dist/rag/retriever.js +2 -2
  59. package/dist/runtime/query/__tests__/context.test.js +8 -7
  60. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  61. package/dist/runtime/query/context-cache.d.ts +3 -3
  62. package/dist/runtime/query/context-cache.d.ts.map +1 -1
  63. package/dist/runtime/query/context-cache.js +2 -2
  64. package/dist/runtime/query/context-cache.js.map +1 -1
  65. package/dist/runtime/query/context.d.ts +12 -21
  66. package/dist/runtime/query/context.d.ts.map +1 -1
  67. package/dist/runtime/query/context.js +3 -1
  68. package/dist/runtime/query/context.js.map +1 -1
  69. package/dist/runtime/query/index.d.ts +13 -15
  70. package/dist/runtime/query/index.d.ts.map +1 -1
  71. package/dist/runtime/query/index.js +1 -0
  72. package/dist/runtime/query/index.js.map +1 -1
  73. package/dist/session/__tests__/integration/_fixtures.d.ts +11 -4
  74. package/dist/session/__tests__/integration/_fixtures.d.ts.map +1 -1
  75. package/dist/session/__tests__/integration/_fixtures.js +23 -6
  76. package/dist/session/__tests__/integration/_fixtures.js.map +1 -1
  77. package/dist/session/__tests__/integration/archive-gate.test.d.ts +15 -0
  78. package/dist/session/__tests__/integration/archive-gate.test.d.ts.map +1 -0
  79. package/dist/session/__tests__/integration/archive-gate.test.js +214 -0
  80. package/dist/session/__tests__/integration/archive-gate.test.js.map +1 -0
  81. package/dist/session/__tests__/integration/capacity-caps.test.js +13 -6
  82. package/dist/session/__tests__/integration/capacity-caps.test.js.map +1 -1
  83. package/dist/session/__tests__/integration/e2e-spawn.test.js +14 -2
  84. package/dist/session/__tests__/integration/e2e-spawn.test.js.map +1 -1
  85. package/dist/session/__tests__/integration/event-stream-ordering.test.js +14 -7
  86. package/dist/session/__tests__/integration/event-stream-ordering.test.js.map +1 -1
  87. package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js +26 -14
  88. package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js.map +1 -1
  89. package/dist/session/__tests__/integration/handoff-illegal-transition.test.js +30 -20
  90. package/dist/session/__tests__/integration/handoff-illegal-transition.test.js.map +1 -1
  91. package/dist/session/__tests__/integration/handoff-single-e2e.test.js +25 -9
  92. package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
  93. package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js +11 -10
  94. package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js.map +1 -1
  95. package/dist/session/__tests__/integration/prev-artifact-dag.test.js +5 -4
  96. package/dist/session/__tests__/integration/prev-artifact-dag.test.js.map +1 -1
  97. package/dist/session/__tests__/integration/retention-archive.test.js +3 -2
  98. package/dist/session/__tests__/integration/retention-archive.test.js.map +1 -1
  99. package/dist/session/__tests__/integration/spawn-rollback.test.d.ts +26 -0
  100. package/dist/session/__tests__/integration/spawn-rollback.test.d.ts.map +1 -0
  101. package/dist/session/__tests__/integration/spawn-rollback.test.js +236 -0
  102. package/dist/session/__tests__/integration/spawn-rollback.test.js.map +1 -0
  103. package/dist/session/__tests__/integration/summary-materialization-e2e.test.js +2 -1
  104. package/dist/session/__tests__/integration/summary-materialization-e2e.test.js.map +1 -1
  105. package/dist/session/__tests__/integration/tenant-isolation.test.js +14 -5
  106. package/dist/session/__tests__/integration/tenant-isolation.test.js.map +1 -1
  107. package/dist/session/errors.d.ts +79 -0
  108. package/dist/session/errors.d.ts.map +1 -1
  109. package/dist/session/errors.js +57 -0
  110. package/dist/session/errors.js.map +1 -1
  111. package/dist/session/handoff/__tests__/broadcast.test.js +49 -31
  112. package/dist/session/handoff/__tests__/broadcast.test.js.map +1 -1
  113. package/dist/session/handoff/__tests__/capacity.test.js +21 -18
  114. package/dist/session/handoff/__tests__/capacity.test.js.map +1 -1
  115. package/dist/session/handoff/__tests__/single.test.js +39 -30
  116. package/dist/session/handoff/__tests__/single.test.js.map +1 -1
  117. package/dist/session/handoff/assignment.d.ts +13 -1
  118. package/dist/session/handoff/assignment.d.ts.map +1 -1
  119. package/dist/session/handoff/broadcast.d.ts +7 -0
  120. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  121. package/dist/session/handoff/broadcast.js +16 -1
  122. package/dist/session/handoff/broadcast.js.map +1 -1
  123. package/dist/session/handoff/single.d.ts +7 -0
  124. package/dist/session/handoff/single.d.ts.map +1 -1
  125. package/dist/session/handoff/single.js +13 -1
  126. package/dist/session/handoff/single.js.map +1 -1
  127. package/dist/session/hierarchy/__tests__/session.test.js +2 -0
  128. package/dist/session/hierarchy/__tests__/session.test.js.map +1 -1
  129. package/dist/session/hierarchy/index.d.ts +1 -0
  130. package/dist/session/hierarchy/index.d.ts.map +1 -1
  131. package/dist/session/hierarchy/index.js.map +1 -1
  132. package/dist/session/hierarchy/session.d.ts +15 -3
  133. package/dist/session/hierarchy/session.d.ts.map +1 -1
  134. package/dist/session/hierarchy/session.js.map +1 -1
  135. package/dist/session/hierarchy/thread.d.ts +54 -0
  136. package/dist/session/hierarchy/thread.d.ts.map +1 -0
  137. package/dist/session/hierarchy/thread.js +2 -0
  138. package/dist/session/hierarchy/thread.js.map +1 -0
  139. package/dist/session/migration/id-prefix.d.ts +8 -13
  140. package/dist/session/migration/id-prefix.d.ts.map +1 -1
  141. package/dist/session/migration/id-prefix.js +8 -13
  142. package/dist/session/migration/id-prefix.js.map +1 -1
  143. package/dist/session/retention/__tests__/archive.test.js +3 -2
  144. package/dist/session/retention/__tests__/archive.test.js.map +1 -1
  145. package/dist/session/summary/__tests__/materialize.test.js +4 -3
  146. package/dist/session/summary/__tests__/materialize.test.js.map +1 -1
  147. package/dist/store/index.d.ts +0 -2
  148. package/dist/store/index.d.ts.map +1 -1
  149. package/dist/store/index.js +0 -1
  150. package/dist/store/index.js.map +1 -1
  151. package/dist/store/session/__tests__/disk.test.js +32 -5
  152. package/dist/store/session/__tests__/disk.test.js.map +1 -1
  153. package/dist/store/session/__tests__/memory.test.js +50 -9
  154. package/dist/store/session/__tests__/memory.test.js.map +1 -1
  155. package/dist/store/session/disk.d.ts +2 -1
  156. package/dist/store/session/disk.d.ts.map +1 -1
  157. package/dist/store/session/disk.js +61 -0
  158. package/dist/store/session/disk.js.map +1 -1
  159. package/dist/store/session/index.d.ts.map +1 -1
  160. package/dist/store/session/index.js +3 -4
  161. package/dist/store/session/index.js.map +1 -1
  162. package/dist/store/session/memory.d.ts +2 -1
  163. package/dist/store/session/memory.d.ts.map +1 -1
  164. package/dist/store/session/memory.js +13 -0
  165. package/dist/store/session/memory.js.map +1 -1
  166. package/dist/store/thread/disk.d.ts +41 -0
  167. package/dist/store/thread/disk.d.ts.map +1 -0
  168. package/dist/store/thread/disk.js +229 -0
  169. package/dist/store/thread/disk.js.map +1 -0
  170. package/dist/store/thread/index.d.ts +4 -0
  171. package/dist/store/thread/index.d.ts.map +1 -0
  172. package/dist/store/thread/index.js +6 -0
  173. package/dist/store/thread/index.js.map +1 -0
  174. package/dist/store/thread/memory.d.ts +23 -0
  175. package/dist/store/thread/memory.d.ts.map +1 -0
  176. package/dist/store/thread/memory.js +90 -0
  177. package/dist/store/thread/memory.js.map +1 -0
  178. package/dist/types/agent/base.d.ts +17 -21
  179. package/dist/types/agent/base.d.ts.map +1 -1
  180. package/dist/types/agent/factory.d.ts +8 -2
  181. package/dist/types/agent/factory.d.ts.map +1 -1
  182. package/dist/types/agent/task.d.ts +18 -11
  183. package/dist/types/agent/task.d.ts.map +1 -1
  184. package/dist/types/ids/index.d.ts +5 -9
  185. package/dist/types/ids/index.d.ts.map +1 -1
  186. package/dist/types/ids/index.js +4 -4
  187. package/dist/types/ids/index.js.map +1 -1
  188. package/dist/types/rag/retrieval.d.ts +4 -3
  189. package/dist/types/rag/retrieval.d.ts.map +1 -1
  190. package/dist/types/run/config.d.ts +6 -5
  191. package/dist/types/run/config.d.ts.map +1 -1
  192. package/dist/types/run/metadata.d.ts +5 -18
  193. package/dist/types/run/metadata.d.ts.map +1 -1
  194. package/dist/types/session/ids.d.ts +4 -13
  195. package/dist/types/session/ids.d.ts.map +1 -1
  196. package/dist/types/session/ids.js +3 -6
  197. package/dist/types/session/ids.js.map +1 -1
  198. package/dist/types/session/index.d.ts +1 -1
  199. package/dist/types/session/index.d.ts.map +1 -1
  200. package/dist/types/session/store.d.ts +32 -10
  201. package/dist/types/session/store.d.ts.map +1 -1
  202. package/dist/types/session/store.js +3 -8
  203. package/dist/types/session/store.js.map +1 -1
  204. package/dist/types/thread/index.d.ts +2 -0
  205. package/dist/types/thread/index.d.ts.map +1 -0
  206. package/dist/types/thread/index.js +5 -0
  207. package/dist/types/thread/index.js.map +1 -0
  208. package/dist/types/thread/store.d.ts +86 -0
  209. package/dist/types/thread/store.d.ts.map +1 -0
  210. package/dist/types/thread/store.js +22 -0
  211. package/dist/types/thread/store.js.map +1 -0
  212. package/dist/utils/id.d.ts +1 -12
  213. package/dist/utils/id.d.ts.map +1 -1
  214. package/dist/utils/id.js +3 -23
  215. package/dist/utils/id.js.map +1 -1
  216. package/package.json +6 -11
  217. package/src/agents/ReactiveAgent.ts +3 -2
  218. package/src/agents/SupervisorAgent.ts +5 -2
  219. package/src/bridge/a2a/index.ts +0 -1
  220. package/src/bridge/a2a/message.ts +0 -32
  221. package/src/bridge/a2a/task.ts +8 -7
  222. package/src/contracts/api.ts +6 -42
  223. package/src/contracts/ids.ts +1 -1
  224. package/src/contracts/index.ts +2 -8
  225. package/src/contracts/schemas.ts +1 -8
  226. package/src/index.ts +0 -4
  227. package/src/manager/agent/__tests__/lifecycle.test.ts +34 -13
  228. package/src/manager/agent/lifecycle.ts +114 -35
  229. package/src/manager/index.ts +3 -0
  230. package/src/manager/run/persistence.ts +7 -1
  231. package/src/manager/thread/__tests__/lifecycle.test.ts +286 -0
  232. package/src/manager/thread/lifecycle.ts +217 -0
  233. package/src/rag/retriever.ts +2 -2
  234. package/src/runtime/query/__tests__/context.test.ts +9 -8
  235. package/src/runtime/query/context-cache.ts +4 -4
  236. package/src/runtime/query/context.ts +15 -22
  237. package/src/runtime/query/index.ts +15 -16
  238. package/src/session/__tests__/integration/_fixtures.ts +36 -8
  239. package/src/session/__tests__/integration/archive-gate.test.ts +288 -0
  240. package/src/session/__tests__/integration/capacity-caps.test.ts +13 -6
  241. package/src/session/__tests__/integration/e2e-spawn.test.ts +20 -2
  242. package/src/session/__tests__/integration/event-stream-ordering.test.ts +14 -7
  243. package/src/session/__tests__/integration/handoff-broadcast-e2e.test.ts +39 -13
  244. package/src/session/__tests__/integration/handoff-illegal-transition.test.ts +54 -19
  245. package/src/session/__tests__/integration/handoff-single-e2e.test.ts +40 -9
  246. package/src/session/__tests__/integration/hierarchy-lifecycle.test.ts +13 -10
  247. package/src/session/__tests__/integration/prev-artifact-dag.test.ts +12 -5
  248. package/src/session/__tests__/integration/retention-archive.test.ts +5 -3
  249. package/src/session/__tests__/integration/spawn-rollback.test.ts +313 -0
  250. package/src/session/__tests__/integration/summary-materialization-e2e.test.ts +4 -2
  251. package/src/session/__tests__/integration/tenant-isolation.test.ts +16 -6
  252. package/src/session/errors.ts +89 -0
  253. package/src/session/handoff/__tests__/broadcast.test.ts +56 -28
  254. package/src/session/handoff/__tests__/capacity.test.ts +26 -20
  255. package/src/session/handoff/__tests__/single.test.ts +45 -28
  256. package/src/session/handoff/assignment.ts +13 -1
  257. package/src/session/handoff/broadcast.ts +26 -1
  258. package/src/session/handoff/single.ts +23 -1
  259. package/src/session/hierarchy/__tests__/session.test.ts +9 -1
  260. package/src/session/hierarchy/index.ts +1 -0
  261. package/src/session/hierarchy/session.ts +15 -3
  262. package/src/session/hierarchy/thread.ts +55 -0
  263. package/src/session/migration/id-prefix.ts +8 -13
  264. package/src/session/retention/__tests__/archive.test.ts +5 -3
  265. package/src/session/summary/__tests__/materialize.test.ts +6 -4
  266. package/src/store/index.ts +0 -3
  267. package/src/store/session/__tests__/disk.test.ts +57 -6
  268. package/src/store/session/__tests__/memory.test.ts +84 -9
  269. package/src/store/session/disk.ts +57 -1
  270. package/src/store/session/index.ts +3 -4
  271. package/src/store/session/memory.ts +13 -1
  272. package/src/store/thread/disk.ts +261 -0
  273. package/src/store/thread/index.ts +7 -0
  274. package/src/store/thread/memory.ts +104 -0
  275. package/src/types/agent/base.ts +17 -21
  276. package/src/types/agent/factory.ts +8 -3
  277. package/src/types/agent/task.ts +19 -11
  278. package/src/types/ids/index.ts +8 -15
  279. package/src/types/rag/retrieval.ts +4 -3
  280. package/src/types/run/config.ts +6 -5
  281. package/src/types/run/metadata.ts +5 -18
  282. package/src/types/session/ids.ts +4 -15
  283. package/src/types/session/index.ts +1 -2
  284. package/src/types/session/store.ts +34 -11
  285. package/src/types/thread/index.ts +5 -0
  286. package/src/types/thread/store.ts +92 -0
  287. package/src/utils/id.ts +3 -24
  288. package/dist/store/conversation/memory.d.ts +0 -43
  289. package/dist/store/conversation/memory.d.ts.map +0 -1
  290. package/dist/store/conversation/memory.js +0 -108
  291. package/dist/store/conversation/memory.js.map +0 -1
  292. package/dist/types/conversation/index.d.ts +0 -14
  293. package/dist/types/conversation/index.d.ts.map +0 -1
  294. package/dist/types/conversation/index.js +0 -2
  295. package/dist/types/conversation/index.js.map +0 -1
  296. package/src/store/conversation/memory.ts +0 -144
  297. package/src/types/conversation/index.ts +0 -15
@@ -23,7 +23,7 @@ function agentActor(tenantId: TenantId): ActorRef {
23
23
  async function seed(store: DiskSessionStore, tenantId: TenantId) {
24
24
  const project = await store.createProject({ tenantId, name: 'p1' }, tenantId)
25
25
  const session = await store.createSession(
26
- { projectId: project.id, currentActor: userActor(tenantId) },
26
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: userActor(tenantId) },
27
27
  tenantId,
28
28
  )
29
29
  return { project, session }
@@ -45,7 +45,7 @@ describe('DiskSessionStore', () => {
45
45
  it('writes the canonical directory layout (projects/.../sessions/.../subsessions)', async () => {
46
46
  const { project, session } = await seed(store, tenantA)
47
47
  const child = await store.createSession(
48
- { projectId: project.id, currentActor: agentActor(tenantA) },
48
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
49
49
  tenantA,
50
50
  )
51
51
  const sub = await store.createSubSession(
@@ -143,7 +143,7 @@ describe('DiskSessionStore', () => {
143
143
  it('drill returns children and ancestry after a cold reload', async () => {
144
144
  const { project, session: root } = await seed(store, tenantA)
145
145
  const child = await store.createSession(
146
- { projectId: project.id, currentActor: agentActor(tenantA) },
146
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
147
147
  tenantA,
148
148
  )
149
149
  await store.createSubSession(
@@ -278,7 +278,7 @@ describe('DiskSessionStore', () => {
278
278
  it('deleteSession rejects if sub-sessions are still attached', async () => {
279
279
  const { project, session: root } = await seed(store, tenantA)
280
280
  const child = await store.createSession(
281
- { projectId: project.id, currentActor: agentActor(tenantA) },
281
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
282
282
  tenantA,
283
283
  )
284
284
  await store.createSubSession(
@@ -297,7 +297,7 @@ describe('DiskSessionStore', () => {
297
297
  it('deleteSubSession removes the sub-session directory and is idempotent', async () => {
298
298
  const { project, session: root } = await seed(store, tenantA)
299
299
  const child = await store.createSession(
300
- { projectId: project.id, currentActor: agentActor(tenantA) },
300
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
301
301
  tenantA,
302
302
  )
303
303
  const sub = await store.createSubSession(
@@ -338,9 +338,60 @@ describe('DiskSessionStore', () => {
338
338
 
339
339
  await expect(store.getSummary(session.id, tenantB)).rejects.toBeInstanceOf(TenantIsolationError)
340
340
  })
341
+
342
+ describe('listSessions(threadId, tenantId)', () => {
343
+ const threadX = 'thd_x' as ThreadId
344
+ const threadY = 'thd_y' as ThreadId
345
+
346
+ it('returns [] when the projects root is empty', async () => {
347
+ // Fresh temp root — no projects directory yet.
348
+ expect(await store.listSessions(threadX, tenantA)).toEqual([])
349
+ })
350
+
351
+ it('filters by threadId and tenant; orders by createdAt ascending', async () => {
352
+ const project = await store.createProject({ tenantId: tenantA, name: 'p' }, tenantA)
353
+
354
+ const first = await store.createSession(
355
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
356
+ tenantA,
357
+ )
358
+ await new Promise((r) => setTimeout(r, 2))
359
+ const second = await store.createSession(
360
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
361
+ tenantA,
362
+ )
363
+ // Same project, different thread — must not appear.
364
+ await store.createSession(
365
+ { threadId: threadY, projectId: project.id, currentActor: userActor(tenantA) },
366
+ tenantA,
367
+ )
368
+
369
+ const listed = await store.listSessions(threadX, tenantA)
370
+ expect(listed.map((s) => s.id)).toEqual([first.id, second.id])
371
+ })
372
+
373
+ it('skips cross-tenant sessions even when threadId matches', async () => {
374
+ const pA = await store.createProject({ tenantId: tenantA, name: 'pa' }, tenantA)
375
+ const pB = await store.createProject({ tenantId: tenantB, name: 'pb' }, tenantB)
376
+
377
+ const own = await store.createSession(
378
+ { threadId: threadX, projectId: pA.id, currentActor: userActor(tenantA) },
379
+ tenantA,
380
+ )
381
+ await store.createSession(
382
+ { threadId: threadX, projectId: pB.id, currentActor: userActor(tenantB) },
383
+ tenantB,
384
+ )
385
+
386
+ const listed = await store.listSessions(threadX, tenantA)
387
+ expect(listed.map((s) => s.id)).toEqual([own.id])
388
+ })
389
+ })
341
390
  })
342
391
 
343
392
  import type { SessionSummaryRef } from '../../../session/summary/ref.js'
344
393
  // Import after use so tests are self-contained w.r.t. types we already use.
345
394
  import type { SessionId } from '../../../types/ids/index.js'
346
- import type { SummaryId } from '../../../types/session/ids.js'
395
+ import type { SummaryId, ThreadId } from '../../../types/session/ids.js'
396
+
397
+ const TEST_THREAD_ID = 'thd_test' as ThreadId
@@ -4,8 +4,11 @@ import type { ActorRef } from '../../../session/hierarchy/actor.js'
4
4
  import type { SubSession } from '../../../session/hierarchy/sub-session.js'
5
5
  import type { AgentId, SessionId, TenantId, UserId } from '../../../types/ids/index.js'
6
6
  import { createUserMessage } from '../../../types/message/index.js'
7
+ import type { ThreadId } from '../../../types/session/ids.js'
7
8
  import { InMemorySessionStore } from '../memory.js'
8
9
 
10
+ const TEST_THREAD_ID = 'thd_test' as ThreadId
11
+
9
12
  function userActor(tenantId: TenantId): ActorRef {
10
13
  return { kind: 'user', userId: 'usr_a' as UserId, tenantId }
11
14
  }
@@ -25,7 +28,7 @@ const tenantB = 'tnt_beta' as TenantId
25
28
  async function seed(store: InMemorySessionStore, tenantId: TenantId) {
26
29
  const project = await store.createProject({ tenantId, name: 'p1' }, tenantId)
27
30
  const session = await store.createSession(
28
- { projectId: project.id, currentActor: userActor(tenantId) },
31
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: userActor(tenantId) },
29
32
  tenantId,
30
33
  )
31
34
  return { project, session }
@@ -84,7 +87,7 @@ describe('InMemorySessionStore', () => {
84
87
 
85
88
  // Create a child session + link via sub-session.
86
89
  const child = await store.createSession(
87
- { projectId: project.id, currentActor: agentActor(tenantA) },
90
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
88
91
  tenantA,
89
92
  )
90
93
  const sub = await store.createSubSession(
@@ -139,7 +142,7 @@ describe('InMemorySessionStore', () => {
139
142
  const { project: pB, session: rootB } = await seed(store, tenantB)
140
143
 
141
144
  const childA = await store.createSession(
142
- { projectId: pA.id, currentActor: agentActor(tenantA) },
145
+ { threadId: TEST_THREAD_ID, projectId: pA.id, currentActor: agentActor(tenantA) },
143
146
  tenantA,
144
147
  )
145
148
  await store.createSubSession(
@@ -153,7 +156,7 @@ describe('InMemorySessionStore', () => {
153
156
  )
154
157
 
155
158
  const childB = await store.createSession(
156
- { projectId: pB.id, currentActor: agentActor(tenantB) },
159
+ { threadId: TEST_THREAD_ID, projectId: pB.id, currentActor: agentActor(tenantB) },
157
160
  tenantB,
158
161
  )
159
162
  await store.createSubSession(
@@ -186,7 +189,7 @@ describe('InMemorySessionStore', () => {
186
189
  const store = new InMemorySessionStore()
187
190
  const { project, session: rootA } = await seed(store, tenantA)
188
191
  const sessionB = await store.createSession(
189
- { projectId: project.id, currentActor: agentActor(tenantA) },
192
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
190
193
  tenantA,
191
194
  )
192
195
 
@@ -235,7 +238,7 @@ describe('InMemorySessionStore', () => {
235
238
  const store = new InMemorySessionStore()
236
239
  const { project, session: root } = await seed(store, tenantA)
237
240
  const child = await store.createSession(
238
- { projectId: project.id, currentActor: agentActor(tenantA) },
241
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
239
242
  tenantA,
240
243
  )
241
244
  await store.createSubSession(
@@ -265,7 +268,7 @@ describe('InMemorySessionStore', () => {
265
268
  const store = new InMemorySessionStore()
266
269
  const { project, session: root } = await seed(store, tenantA)
267
270
  const child = await store.createSession(
268
- { projectId: project.id, currentActor: agentActor(tenantA) },
271
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
269
272
  tenantA,
270
273
  )
271
274
  const sub = await store.createSubSession(
@@ -293,11 +296,11 @@ describe('InMemorySessionStore', () => {
293
296
  const { project, session: root } = await seed(store, tenantA)
294
297
 
295
298
  const c1 = await store.createSession(
296
- { projectId: project.id, currentActor: agentActor(tenantA) },
299
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
297
300
  tenantA,
298
301
  )
299
302
  const c2 = await store.createSession(
300
- { projectId: project.id, currentActor: agentActor(tenantA) },
303
+ { threadId: TEST_THREAD_ID, projectId: project.id, currentActor: agentActor(tenantA) },
301
304
  tenantA,
302
305
  )
303
306
 
@@ -324,4 +327,76 @@ describe('InMemorySessionStore', () => {
324
327
  const ids = children.map((s: SubSession) => s.id)
325
328
  expect(new Set(ids)).toEqual(new Set([s1.id, s2.id]))
326
329
  })
330
+
331
+ describe('listSessions(threadId, tenantId)', () => {
332
+ const threadX = 'thd_x' as ThreadId
333
+ const threadY = 'thd_y' as ThreadId
334
+
335
+ it('returns [] when the thread has no sessions', async () => {
336
+ const store = new InMemorySessionStore()
337
+ expect(await store.listSessions(threadX, tenantA)).toEqual([])
338
+ })
339
+
340
+ it('returns only sessions whose threadId matches, for the caller tenant', async () => {
341
+ const store = new InMemorySessionStore()
342
+ const project = await store.createProject({ tenantId: tenantA, name: 'p' }, tenantA)
343
+
344
+ const sX1 = await store.createSession(
345
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
346
+ tenantA,
347
+ )
348
+ const sX2 = await store.createSession(
349
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
350
+ tenantA,
351
+ )
352
+ // Same project, different thread — must not appear.
353
+ await store.createSession(
354
+ { threadId: threadY, projectId: project.id, currentActor: userActor(tenantA) },
355
+ tenantA,
356
+ )
357
+
358
+ const listed = await store.listSessions(threadX, tenantA)
359
+ expect(listed.map((s) => s.id).sort()).toEqual([sX1.id, sX2.id].sort())
360
+ })
361
+
362
+ it('orders results by createdAt ascending', async () => {
363
+ const store = new InMemorySessionStore()
364
+ const project = await store.createProject({ tenantId: tenantA, name: 'p' }, tenantA)
365
+
366
+ const first = await store.createSession(
367
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
368
+ tenantA,
369
+ )
370
+ // Nudge clock for deterministic ordering; in-memory uses `new Date()`.
371
+ await new Promise((r) => setTimeout(r, 2))
372
+ const second = await store.createSession(
373
+ { threadId: threadX, projectId: project.id, currentActor: userActor(tenantA) },
374
+ tenantA,
375
+ )
376
+
377
+ const listed = await store.listSessions(threadX, tenantA)
378
+ expect(listed.map((s) => s.id)).toEqual([first.id, second.id])
379
+ })
380
+
381
+ it('silently skips cross-tenant sessions sharing the same threadId', async () => {
382
+ // Thread ids are tenant-scoped in practice but nothing at the type
383
+ // level prevents the same string identifier being reused across
384
+ // tenants — the listing must filter by tenant without erroring.
385
+ const store = new InMemorySessionStore()
386
+ const pA = await store.createProject({ tenantId: tenantA, name: 'pa' }, tenantA)
387
+ const pB = await store.createProject({ tenantId: tenantB, name: 'pb' }, tenantB)
388
+
389
+ const own = await store.createSession(
390
+ { threadId: threadX, projectId: pA.id, currentActor: userActor(tenantA) },
391
+ tenantA,
392
+ )
393
+ await store.createSession(
394
+ { threadId: threadX, projectId: pB.id, currentActor: userActor(tenantB) },
395
+ tenantB,
396
+ )
397
+
398
+ const listed = await store.listSessions(threadX, tenantA)
399
+ expect(listed.map((s) => s.id)).toEqual([own.id])
400
+ })
401
+ })
327
402
  })
@@ -45,7 +45,7 @@ import type {
45
45
  } from '../../session/summary/ref.js'
46
46
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
47
47
  import type { Message } from '../../types/message/index.js'
48
- import type { ProjectId, SubSessionId, SummaryId } from '../../types/session/ids.js'
48
+ import type { ProjectId, SubSessionId, SummaryId, ThreadId } from '../../types/session/ids.js'
49
49
  import type {
50
50
  CreateProjectParams,
51
51
  CreateSessionParams,
@@ -82,6 +82,7 @@ interface PersistedProject {
82
82
 
83
83
  interface PersistedSession {
84
84
  id: SessionId
85
+ threadId: ThreadId
85
86
  projectId: ProjectId
86
87
  tenantId: TenantId
87
88
  status: Session['status']
@@ -221,6 +222,7 @@ export class DiskSessionStore implements SessionStore {
221
222
  const now = new Date()
222
223
  const session: Session = {
223
224
  id: generateSessionId(),
225
+ threadId: params.threadId,
224
226
  projectId: params.projectId,
225
227
  tenantId,
226
228
  status: 'idle',
@@ -251,6 +253,58 @@ export class DiskSessionStore implements SessionStore {
251
253
  return deserializeSession(raw)
252
254
  }
253
255
 
256
+ async listSessions(threadId: ThreadId, tenantId: TenantId): Promise<readonly Session[]> {
257
+ // Walk projects/*/sessions/* and filter on the persisted record. Sessions
258
+ // don't live under a thread-scoped path in the current layout — the
259
+ // denormalized `threadId` on every session.json is the authority. Matches
260
+ // DiskThreadStore.listThreads in scan semantics.
261
+ //
262
+ // Cost: O(all sessions across all projects in the root) per call. The
263
+ // MVP disk store prioritizes simplicity over index freshness, matching
264
+ // `buildLinkageView` / `locateSession` which use the same pattern. A
265
+ // production driver would maintain a threadId → sessionIds secondary
266
+ // index populated on createSession / deleteSession. Acceptable for
267
+ // ThreadManager archive/delete today because those operations are
268
+ // admin-initiated and infrequent.
269
+ const projectsDir = join(this.rootDir, 'projects')
270
+ let projectDirs: string[]
271
+ try {
272
+ projectDirs = await readdir(projectsDir)
273
+ } catch (err) {
274
+ const code = (err as NodeJS.ErrnoException).code
275
+ if (code === 'ENOENT') return []
276
+ throw err
277
+ }
278
+
279
+ const results: Session[] = []
280
+ for (const rawProject of projectDirs) {
281
+ if (!rawProject.startsWith('prj_')) continue
282
+ const sessionsRoot = join(projectsDir, rawProject, 'sessions')
283
+ let sessionDirs: string[]
284
+ try {
285
+ sessionDirs = await readdir(sessionsRoot)
286
+ } catch {
287
+ continue
288
+ }
289
+ for (const rawSessionId of sessionDirs) {
290
+ if (!rawSessionId.startsWith('ses_')) continue
291
+ const path = join(sessionsRoot, rawSessionId)
292
+ const raw = await readJson<PersistedSession>(join(path, 'session.json'))
293
+ if (!raw) continue
294
+ if (raw.tenantId !== tenantId) continue
295
+ if (raw.threadId !== threadId) continue
296
+ results.push(deserializeSession(raw))
297
+ this.sessionIndex.set(raw.id, {
298
+ sessionId: raw.id,
299
+ projectId: rawProject as ProjectId,
300
+ path,
301
+ })
302
+ }
303
+ }
304
+ results.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
305
+ return results
306
+ }
307
+
254
308
  async updateSession(session: Session, tenantId: TenantId): Promise<void> {
255
309
  const located = await this.locateSession(session.id)
256
310
  if (!located) {
@@ -785,6 +839,7 @@ function deserializeProject(p: PersistedProject): Project {
785
839
  function serializeSession(s: Session): PersistedSession {
786
840
  return {
787
841
  id: s.id,
842
+ threadId: s.threadId,
788
843
  projectId: s.projectId,
789
844
  tenantId: s.tenantId,
790
845
  status: s.status,
@@ -800,6 +855,7 @@ function serializeSession(s: Session): PersistedSession {
800
855
  function deserializeSession(s: PersistedSession): Session {
801
856
  return {
802
857
  id: s.id,
858
+ threadId: s.threadId,
803
859
  projectId: s.projectId,
804
860
  tenantId: s.tenantId,
805
861
  status: s.status,
@@ -1,9 +1,8 @@
1
1
  // Sub-barrel for the session-scoped persistence module (Convention #4).
2
2
  //
3
- // `SessionStore` replaces the legacy `ConversationStore`; messages are scoped
4
- // to a `SessionId` (not a bare thread) and every accessor carries explicit
5
- // `TenantId` per session-hierarchy.md §12.1. Concrete implementations live
6
- // in sibling files; re-export them here so consumers import via
3
+ // Messages are scoped to a `SessionId` and every accessor carries explicit
4
+ // `TenantId` (Convention #17). Concrete implementations live in sibling
5
+ // files; re-export them here so consumers import via
7
6
  // `../store/session/index.js`.
8
7
 
9
8
  export { InMemorySessionStore } from './memory.js'
@@ -16,7 +16,7 @@ import { SessionAlreadySummarizedError } from '../../session/summary/ref.js'
16
16
  import type { SessionSummaryRef } from '../../session/summary/ref.js'
17
17
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
18
18
  import type { Message } from '../../types/message/index.js'
19
- import type { ProjectId, SubSessionId } from '../../types/session/ids.js'
19
+ import type { ProjectId, SubSessionId, ThreadId } from '../../types/session/ids.js'
20
20
  import type {
21
21
  CreateProjectParams,
22
22
  CreateSessionParams,
@@ -118,6 +118,7 @@ export class InMemorySessionStore implements SessionStore {
118
118
  const now = new Date()
119
119
  const session: Session = {
120
120
  id: generateSessionId(),
121
+ threadId: params.threadId,
121
122
  projectId: params.projectId,
122
123
  tenantId,
123
124
  status: 'idle',
@@ -139,6 +140,17 @@ export class InMemorySessionStore implements SessionStore {
139
140
  return record.session
140
141
  }
141
142
 
143
+ async listSessions(threadId: ThreadId, tenantId: TenantId): Promise<readonly Session[]> {
144
+ const matches: Session[] = []
145
+ for (const record of this.sessions.values()) {
146
+ if (record.tenantId !== tenantId) continue
147
+ if (record.session.threadId !== threadId) continue
148
+ matches.push(record.session)
149
+ }
150
+ matches.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
151
+ return matches
152
+ }
153
+
142
154
  async updateSession(session: Session, tenantId: TenantId): Promise<void> {
143
155
  const record = this.sessions.get(session.id)
144
156
  if (!record) {
@@ -0,0 +1,261 @@
1
+ /**
2
+ * DiskThreadStore — filesystem-backed implementation of {@link ThreadStore}.
3
+ *
4
+ * Every mutation is write-tmp-rename (Convention #8). Layout uses the Phase 2
5
+ * intermediate shape:
6
+ *
7
+ * {rootDir}/projects/{projectId}/threads/{threadId}/
8
+ * thread.json
9
+ *
10
+ * Sessions stay under `projects/{projectId}/sessions/{sessionId}/...` rather
11
+ * than nesting under `threads/{threadId}/` — the denormalized `threadId` on
12
+ * each session record (Phase 2.4 decision) makes thread-scoped queries
13
+ * addressable without path-level nesting, and keeps Project-scoped consumers
14
+ * (handoff, retention, archival) a single-directory scan. Phase 6 collapses
15
+ * `projects/{projectId}/` to `.namzu/` as part of `namzu init` folder binding.
16
+ *
17
+ * Tenant scoping is enforced through the JSON payload (`tenantId` field on
18
+ * every record), not the path — cross-tenant reads reject with
19
+ * {@link TenantIsolationError} (Convention #17).
20
+ */
21
+
22
+ import { mkdir, readFile, readdir, rename, rm, unlink, writeFile } from 'node:fs/promises'
23
+ import { join } from 'node:path'
24
+ import { StaleThreadError, TenantIsolationError } from '../../session/errors.js'
25
+ import type { Thread, ThreadStatus } from '../../session/hierarchy/thread.js'
26
+ import type { TenantId } from '../../types/ids/index.js'
27
+ import type { ProjectId, ThreadId } from '../../types/session/ids.js'
28
+ import type { CreateThreadParams, ThreadStore } from '../../types/thread/store.js'
29
+ import { generateThreadId } from '../../utils/id.js'
30
+
31
+ /** Config for {@link DiskThreadStore}. `rootDir` is absolute. */
32
+ export interface DiskThreadStoreConfig {
33
+ rootDir: string
34
+ }
35
+
36
+ interface PersistedThread {
37
+ id: ThreadId
38
+ projectId: ProjectId
39
+ tenantId: TenantId
40
+ title: string
41
+ status: ThreadStatus
42
+ ownerVersion: number
43
+ createdAt: string
44
+ updatedAt: string
45
+ }
46
+
47
+ /** Index of threadId → (projectId, path). Lazy; populated on create / lookup. */
48
+ interface ThreadIndexEntry {
49
+ threadId: ThreadId
50
+ projectId: ProjectId
51
+ path: string
52
+ }
53
+
54
+ export class DiskThreadStore implements ThreadStore {
55
+ private readonly rootDir: string
56
+ private readonly threadIndex = new Map<ThreadId, ThreadIndexEntry>()
57
+
58
+ constructor(config: DiskThreadStoreConfig) {
59
+ this.rootDir = config.rootDir
60
+ }
61
+
62
+ async createThread(params: CreateThreadParams, tenantId: TenantId): Promise<Thread> {
63
+ const now = new Date()
64
+ const thread: Thread = {
65
+ id: generateThreadId(),
66
+ projectId: params.projectId,
67
+ tenantId,
68
+ title: params.title,
69
+ status: 'open',
70
+ ownerVersion: 0,
71
+ createdAt: now,
72
+ updatedAt: now,
73
+ }
74
+ const dir = join(this.rootDir, 'projects', params.projectId, 'threads', thread.id)
75
+ await mkdir(dir, { recursive: true })
76
+ await atomicWriteJson(join(dir, 'thread.json'), serializeThread(thread))
77
+ this.threadIndex.set(thread.id, {
78
+ threadId: thread.id,
79
+ projectId: params.projectId,
80
+ path: dir,
81
+ })
82
+ return thread
83
+ }
84
+
85
+ async getThread(threadId: ThreadId, tenantId: TenantId): Promise<Thread | null> {
86
+ const located = await this.locateThread(threadId)
87
+ if (!located) return null
88
+ const raw = await readJson<PersistedThread>(join(located.path, 'thread.json'))
89
+ if (!raw) return null
90
+ this.assertTenant(raw.tenantId, tenantId, `thread(${threadId})`)
91
+ return deserializeThread(raw)
92
+ }
93
+
94
+ async updateThread(thread: Thread, tenantId: TenantId): Promise<void> {
95
+ if (thread.tenantId !== tenantId) {
96
+ throw new TenantIsolationError({
97
+ requested: tenantId,
98
+ resource: `thread(${thread.id}) payload`,
99
+ })
100
+ }
101
+ const located = await this.locateThread(thread.id)
102
+ if (!located) {
103
+ throw new Error(`Thread ${thread.id} not found`)
104
+ }
105
+ const existing = await readJson<PersistedThread>(join(located.path, 'thread.json'))
106
+ if (!existing) {
107
+ throw new Error(`Thread ${thread.id} not found`)
108
+ }
109
+ this.assertTenant(existing.tenantId, tenantId, `thread(${thread.id})`)
110
+
111
+ // CAS on ownerVersion. On mismatch, caller must re-read + re-apply +
112
+ // retry. No silent overwrite (Convention #0).
113
+ if (thread.ownerVersion !== existing.ownerVersion) {
114
+ throw new StaleThreadError({
115
+ threadId: thread.id,
116
+ expectedVersion: thread.ownerVersion,
117
+ actualVersion: existing.ownerVersion,
118
+ })
119
+ }
120
+
121
+ const updated: Thread = {
122
+ ...thread,
123
+ ownerVersion: existing.ownerVersion + 1,
124
+ updatedAt: new Date(),
125
+ }
126
+ await atomicWriteJson(join(located.path, 'thread.json'), serializeThread(updated))
127
+ }
128
+
129
+ async deleteThread(threadId: ThreadId, tenantId: TenantId): Promise<void> {
130
+ const located = await this.locateThread(threadId)
131
+ if (!located) return // Idempotent: missing = no-op.
132
+ const existing = await readJson<PersistedThread>(join(located.path, 'thread.json'))
133
+ if (!existing) return
134
+ this.assertTenant(existing.tenantId, tenantId, `thread(${threadId})`)
135
+
136
+ // The store does NOT enforce "no attached sessions" here — that is a
137
+ // cross-store precondition owned by ThreadManager. This keeps the
138
+ // boundary clean; ThreadStore has no awareness of SessionStore layout.
139
+ await rm(located.path, { recursive: true, force: true })
140
+ this.threadIndex.delete(threadId)
141
+ }
142
+
143
+ async listThreads(projectId: ProjectId, tenantId: TenantId): Promise<readonly Thread[]> {
144
+ const threadsDir = join(this.rootDir, 'projects', projectId, 'threads')
145
+ let entries: string[]
146
+ try {
147
+ entries = await readdir(threadsDir)
148
+ } catch (err) {
149
+ const code = (err as NodeJS.ErrnoException).code
150
+ if (code === 'ENOENT') return []
151
+ throw err
152
+ }
153
+
154
+ const results: Thread[] = []
155
+ for (const entry of entries) {
156
+ if (!entry.startsWith('thd_')) continue
157
+ const path = join(threadsDir, entry)
158
+ const raw = await readJson<PersistedThread>(join(path, 'thread.json'))
159
+ if (!raw) continue
160
+ // Cross-tenant records in the same directory are skipped silently —
161
+ // the listing is scoped to the caller's tenant. Mismatch is not an
162
+ // isolation violation because the caller never requested the record.
163
+ if (raw.tenantId !== tenantId) continue
164
+ results.push(deserializeThread(raw))
165
+ this.threadIndex.set(raw.id, { threadId: raw.id, projectId, path })
166
+ }
167
+ results.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
168
+ return results
169
+ }
170
+
171
+ private async locateThread(threadId: ThreadId): Promise<ThreadIndexEntry | null> {
172
+ const cached = this.threadIndex.get(threadId)
173
+ if (cached) return cached
174
+
175
+ // Walk projects/* looking for the thread dir. Cost is bounded by number
176
+ // of projects (usually 1) × number of threads per project.
177
+ const projectsDir = join(this.rootDir, 'projects')
178
+ let projectDirs: string[]
179
+ try {
180
+ projectDirs = await readdir(projectsDir)
181
+ } catch (err) {
182
+ const code = (err as NodeJS.ErrnoException).code
183
+ if (code === 'ENOENT') return null
184
+ throw err
185
+ }
186
+
187
+ for (const rawProject of projectDirs) {
188
+ if (!rawProject.startsWith('prj_')) continue
189
+ const threadPath = join(projectsDir, rawProject, 'threads', threadId)
190
+ const raw = await readJson<PersistedThread>(join(threadPath, 'thread.json'))
191
+ if (raw?.id === threadId) {
192
+ const entry: ThreadIndexEntry = {
193
+ threadId,
194
+ projectId: rawProject as ProjectId,
195
+ path: threadPath,
196
+ }
197
+ this.threadIndex.set(threadId, entry)
198
+ return entry
199
+ }
200
+ }
201
+ return null
202
+ }
203
+
204
+ private assertTenant(owning: TenantId, requested: TenantId, resource: string): void {
205
+ if (owning !== requested) {
206
+ throw new TenantIsolationError({ requested, resource })
207
+ }
208
+ }
209
+ }
210
+
211
+ // Serialization -----------------------------------------------------------
212
+
213
+ function serializeThread(thread: Thread): PersistedThread {
214
+ return {
215
+ id: thread.id,
216
+ projectId: thread.projectId,
217
+ tenantId: thread.tenantId,
218
+ title: thread.title,
219
+ status: thread.status,
220
+ ownerVersion: thread.ownerVersion,
221
+ createdAt: thread.createdAt.toISOString(),
222
+ updatedAt: thread.updatedAt.toISOString(),
223
+ }
224
+ }
225
+
226
+ function deserializeThread(raw: PersistedThread): Thread {
227
+ return {
228
+ id: raw.id,
229
+ projectId: raw.projectId,
230
+ tenantId: raw.tenantId,
231
+ title: raw.title,
232
+ status: raw.status,
233
+ ownerVersion: raw.ownerVersion,
234
+ createdAt: new Date(raw.createdAt),
235
+ updatedAt: new Date(raw.updatedAt),
236
+ }
237
+ }
238
+
239
+ // FS helpers -----------------------------------------------------------------
240
+
241
+ async function readJson<T>(path: string): Promise<T | null> {
242
+ try {
243
+ const raw = await readFile(path, 'utf-8')
244
+ return JSON.parse(raw) as T
245
+ } catch (err) {
246
+ const code = (err as NodeJS.ErrnoException).code
247
+ if (code === 'ENOENT') return null
248
+ throw err
249
+ }
250
+ }
251
+
252
+ async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
253
+ const tempPath = `${filePath}.tmp`
254
+ try {
255
+ await writeFile(tempPath, JSON.stringify(value, null, 2), 'utf-8')
256
+ await rename(tempPath, filePath)
257
+ } catch (err) {
258
+ await unlink(tempPath).catch(() => undefined)
259
+ throw err
260
+ }
261
+ }
@@ -0,0 +1,7 @@
1
+ // Sub-barrel for the Thread persistence module (Convention #4).
2
+ // Concrete implementations live in sibling files; re-export them here so
3
+ // consumers import via `../store/thread/index.js`.
4
+
5
+ export { InMemoryThreadStore } from './memory.js'
6
+ export { DiskThreadStore } from './disk.js'
7
+ export type { DiskThreadStoreConfig } from './disk.js'