@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.
- package/CHANGELOG.md +53 -2
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +3 -2
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +5 -2
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/bridge/a2a/index.d.ts +1 -1
- package/dist/bridge/a2a/index.d.ts.map +1 -1
- package/dist/bridge/a2a/index.js +1 -1
- package/dist/bridge/a2a/index.js.map +1 -1
- package/dist/bridge/a2a/message.d.ts +0 -2
- package/dist/bridge/a2a/message.d.ts.map +1 -1
- package/dist/bridge/a2a/message.js +0 -26
- package/dist/bridge/a2a/message.js.map +1 -1
- package/dist/bridge/a2a/task.d.ts +4 -3
- package/dist/bridge/a2a/task.d.ts.map +1 -1
- package/dist/bridge/a2a/task.js +4 -4
- package/dist/bridge/a2a/task.js.map +1 -1
- package/dist/contracts/api.d.ts +6 -38
- package/dist/contracts/api.d.ts.map +1 -1
- package/dist/contracts/ids.d.ts +1 -1
- package/dist/contracts/ids.d.ts.map +1 -1
- package/dist/contracts/index.d.ts +3 -5
- package/dist/contracts/index.d.ts.map +1 -1
- package/dist/contracts/index.js +1 -1
- package/dist/contracts/index.js.map +1 -1
- package/dist/contracts/schemas.d.ts +1 -31
- package/dist/contracts/schemas.d.ts.map +1 -1
- package/dist/contracts/schemas.js +1 -7
- package/dist/contracts/schemas.js.map +1 -1
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -3
- package/dist/index.js.map +1 -1
- package/dist/manager/agent/__tests__/lifecycle.test.js +27 -13
- package/dist/manager/agent/__tests__/lifecycle.test.js.map +1 -1
- package/dist/manager/agent/lifecycle.d.ts +9 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +93 -31
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/manager/index.d.ts +2 -0
- package/dist/manager/index.d.ts.map +1 -1
- package/dist/manager/index.js +1 -0
- package/dist/manager/index.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +3 -1
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +5 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/manager/thread/__tests__/lifecycle.test.d.ts +2 -0
- package/dist/manager/thread/__tests__/lifecycle.test.d.ts.map +1 -0
- package/dist/manager/thread/__tests__/lifecycle.test.js +216 -0
- package/dist/manager/thread/__tests__/lifecycle.test.js.map +1 -0
- package/dist/manager/thread/lifecycle.d.ts +105 -0
- package/dist/manager/thread/lifecycle.d.ts.map +1 -0
- package/dist/manager/thread/lifecycle.js +186 -0
- package/dist/manager/thread/lifecycle.js.map +1 -0
- package/dist/rag/retriever.js +2 -2
- package/dist/runtime/query/__tests__/context.test.js +8 -7
- package/dist/runtime/query/__tests__/context.test.js.map +1 -1
- package/dist/runtime/query/context-cache.d.ts +3 -3
- package/dist/runtime/query/context-cache.d.ts.map +1 -1
- package/dist/runtime/query/context-cache.js +2 -2
- package/dist/runtime/query/context-cache.js.map +1 -1
- package/dist/runtime/query/context.d.ts +12 -21
- package/dist/runtime/query/context.d.ts.map +1 -1
- package/dist/runtime/query/context.js +3 -1
- package/dist/runtime/query/context.js.map +1 -1
- package/dist/runtime/query/index.d.ts +13 -15
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/session/__tests__/integration/_fixtures.d.ts +11 -4
- package/dist/session/__tests__/integration/_fixtures.d.ts.map +1 -1
- package/dist/session/__tests__/integration/_fixtures.js +23 -6
- package/dist/session/__tests__/integration/_fixtures.js.map +1 -1
- package/dist/session/__tests__/integration/archive-gate.test.d.ts +15 -0
- package/dist/session/__tests__/integration/archive-gate.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/archive-gate.test.js +214 -0
- package/dist/session/__tests__/integration/archive-gate.test.js.map +1 -0
- package/dist/session/__tests__/integration/capacity-caps.test.js +13 -6
- package/dist/session/__tests__/integration/capacity-caps.test.js.map +1 -1
- package/dist/session/__tests__/integration/e2e-spawn.test.js +14 -2
- package/dist/session/__tests__/integration/e2e-spawn.test.js.map +1 -1
- package/dist/session/__tests__/integration/event-stream-ordering.test.js +14 -7
- package/dist/session/__tests__/integration/event-stream-ordering.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js +26 -14
- package/dist/session/__tests__/integration/handoff-broadcast-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-illegal-transition.test.js +30 -20
- package/dist/session/__tests__/integration/handoff-illegal-transition.test.js.map +1 -1
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js +25 -9
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js +11 -10
- package/dist/session/__tests__/integration/hierarchy-lifecycle.test.js.map +1 -1
- package/dist/session/__tests__/integration/prev-artifact-dag.test.js +5 -4
- package/dist/session/__tests__/integration/prev-artifact-dag.test.js.map +1 -1
- package/dist/session/__tests__/integration/retention-archive.test.js +3 -2
- package/dist/session/__tests__/integration/retention-archive.test.js.map +1 -1
- package/dist/session/__tests__/integration/spawn-rollback.test.d.ts +26 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.js +236 -0
- package/dist/session/__tests__/integration/spawn-rollback.test.js.map +1 -0
- package/dist/session/__tests__/integration/summary-materialization-e2e.test.js +2 -1
- package/dist/session/__tests__/integration/summary-materialization-e2e.test.js.map +1 -1
- package/dist/session/__tests__/integration/tenant-isolation.test.js +14 -5
- package/dist/session/__tests__/integration/tenant-isolation.test.js.map +1 -1
- package/dist/session/errors.d.ts +79 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +57 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/__tests__/broadcast.test.js +49 -31
- package/dist/session/handoff/__tests__/broadcast.test.js.map +1 -1
- package/dist/session/handoff/__tests__/capacity.test.js +21 -18
- package/dist/session/handoff/__tests__/capacity.test.js.map +1 -1
- package/dist/session/handoff/__tests__/single.test.js +39 -30
- package/dist/session/handoff/__tests__/single.test.js.map +1 -1
- package/dist/session/handoff/assignment.d.ts +13 -1
- package/dist/session/handoff/assignment.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.d.ts +7 -0
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +16 -1
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts +7 -0
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +13 -1
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/session/hierarchy/__tests__/session.test.js +2 -0
- package/dist/session/hierarchy/__tests__/session.test.js.map +1 -1
- package/dist/session/hierarchy/index.d.ts +1 -0
- package/dist/session/hierarchy/index.d.ts.map +1 -1
- package/dist/session/hierarchy/index.js.map +1 -1
- package/dist/session/hierarchy/session.d.ts +15 -3
- package/dist/session/hierarchy/session.d.ts.map +1 -1
- package/dist/session/hierarchy/session.js.map +1 -1
- package/dist/session/hierarchy/thread.d.ts +54 -0
- package/dist/session/hierarchy/thread.d.ts.map +1 -0
- package/dist/session/hierarchy/thread.js +2 -0
- package/dist/session/hierarchy/thread.js.map +1 -0
- package/dist/session/migration/id-prefix.d.ts +8 -13
- package/dist/session/migration/id-prefix.d.ts.map +1 -1
- package/dist/session/migration/id-prefix.js +8 -13
- package/dist/session/migration/id-prefix.js.map +1 -1
- package/dist/session/retention/__tests__/archive.test.js +3 -2
- package/dist/session/retention/__tests__/archive.test.js.map +1 -1
- package/dist/session/summary/__tests__/materialize.test.js +4 -3
- package/dist/session/summary/__tests__/materialize.test.js.map +1 -1
- package/dist/store/index.d.ts +0 -2
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +0 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/session/__tests__/disk.test.js +32 -5
- package/dist/store/session/__tests__/disk.test.js.map +1 -1
- package/dist/store/session/__tests__/memory.test.js +50 -9
- package/dist/store/session/__tests__/memory.test.js.map +1 -1
- package/dist/store/session/disk.d.ts +2 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +61 -0
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/index.d.ts.map +1 -1
- package/dist/store/session/index.js +3 -4
- package/dist/store/session/index.js.map +1 -1
- package/dist/store/session/memory.d.ts +2 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +13 -0
- package/dist/store/session/memory.js.map +1 -1
- package/dist/store/thread/disk.d.ts +41 -0
- package/dist/store/thread/disk.d.ts.map +1 -0
- package/dist/store/thread/disk.js +229 -0
- package/dist/store/thread/disk.js.map +1 -0
- package/dist/store/thread/index.d.ts +4 -0
- package/dist/store/thread/index.d.ts.map +1 -0
- package/dist/store/thread/index.js +6 -0
- package/dist/store/thread/index.js.map +1 -0
- package/dist/store/thread/memory.d.ts +23 -0
- package/dist/store/thread/memory.d.ts.map +1 -0
- package/dist/store/thread/memory.js +90 -0
- package/dist/store/thread/memory.js.map +1 -0
- package/dist/types/agent/base.d.ts +17 -21
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/agent/factory.d.ts +8 -2
- package/dist/types/agent/factory.d.ts.map +1 -1
- package/dist/types/agent/task.d.ts +18 -11
- package/dist/types/agent/task.d.ts.map +1 -1
- package/dist/types/ids/index.d.ts +5 -9
- package/dist/types/ids/index.d.ts.map +1 -1
- package/dist/types/ids/index.js +4 -4
- package/dist/types/ids/index.js.map +1 -1
- package/dist/types/rag/retrieval.d.ts +4 -3
- package/dist/types/rag/retrieval.d.ts.map +1 -1
- package/dist/types/run/config.d.ts +6 -5
- package/dist/types/run/config.d.ts.map +1 -1
- package/dist/types/run/metadata.d.ts +5 -18
- package/dist/types/run/metadata.d.ts.map +1 -1
- package/dist/types/session/ids.d.ts +4 -13
- package/dist/types/session/ids.d.ts.map +1 -1
- package/dist/types/session/ids.js +3 -6
- package/dist/types/session/ids.js.map +1 -1
- package/dist/types/session/index.d.ts +1 -1
- package/dist/types/session/index.d.ts.map +1 -1
- package/dist/types/session/store.d.ts +32 -10
- package/dist/types/session/store.d.ts.map +1 -1
- package/dist/types/session/store.js +3 -8
- package/dist/types/session/store.js.map +1 -1
- package/dist/types/thread/index.d.ts +2 -0
- package/dist/types/thread/index.d.ts.map +1 -0
- package/dist/types/thread/index.js +5 -0
- package/dist/types/thread/index.js.map +1 -0
- package/dist/types/thread/store.d.ts +86 -0
- package/dist/types/thread/store.d.ts.map +1 -0
- package/dist/types/thread/store.js +22 -0
- package/dist/types/thread/store.js.map +1 -0
- package/dist/utils/id.d.ts +1 -12
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +3 -23
- package/dist/utils/id.js.map +1 -1
- package/package.json +6 -11
- package/src/agents/ReactiveAgent.ts +3 -2
- package/src/agents/SupervisorAgent.ts +5 -2
- package/src/bridge/a2a/index.ts +0 -1
- package/src/bridge/a2a/message.ts +0 -32
- package/src/bridge/a2a/task.ts +8 -7
- package/src/contracts/api.ts +6 -42
- package/src/contracts/ids.ts +1 -1
- package/src/contracts/index.ts +2 -8
- package/src/contracts/schemas.ts +1 -8
- package/src/index.ts +0 -4
- package/src/manager/agent/__tests__/lifecycle.test.ts +34 -13
- package/src/manager/agent/lifecycle.ts +114 -35
- package/src/manager/index.ts +3 -0
- package/src/manager/run/persistence.ts +7 -1
- package/src/manager/thread/__tests__/lifecycle.test.ts +286 -0
- package/src/manager/thread/lifecycle.ts +217 -0
- package/src/rag/retriever.ts +2 -2
- package/src/runtime/query/__tests__/context.test.ts +9 -8
- package/src/runtime/query/context-cache.ts +4 -4
- package/src/runtime/query/context.ts +15 -22
- package/src/runtime/query/index.ts +15 -16
- package/src/session/__tests__/integration/_fixtures.ts +36 -8
- package/src/session/__tests__/integration/archive-gate.test.ts +288 -0
- package/src/session/__tests__/integration/capacity-caps.test.ts +13 -6
- package/src/session/__tests__/integration/e2e-spawn.test.ts +20 -2
- package/src/session/__tests__/integration/event-stream-ordering.test.ts +14 -7
- package/src/session/__tests__/integration/handoff-broadcast-e2e.test.ts +39 -13
- package/src/session/__tests__/integration/handoff-illegal-transition.test.ts +54 -19
- package/src/session/__tests__/integration/handoff-single-e2e.test.ts +40 -9
- package/src/session/__tests__/integration/hierarchy-lifecycle.test.ts +13 -10
- package/src/session/__tests__/integration/prev-artifact-dag.test.ts +12 -5
- package/src/session/__tests__/integration/retention-archive.test.ts +5 -3
- package/src/session/__tests__/integration/spawn-rollback.test.ts +313 -0
- package/src/session/__tests__/integration/summary-materialization-e2e.test.ts +4 -2
- package/src/session/__tests__/integration/tenant-isolation.test.ts +16 -6
- package/src/session/errors.ts +89 -0
- package/src/session/handoff/__tests__/broadcast.test.ts +56 -28
- package/src/session/handoff/__tests__/capacity.test.ts +26 -20
- package/src/session/handoff/__tests__/single.test.ts +45 -28
- package/src/session/handoff/assignment.ts +13 -1
- package/src/session/handoff/broadcast.ts +26 -1
- package/src/session/handoff/single.ts +23 -1
- package/src/session/hierarchy/__tests__/session.test.ts +9 -1
- package/src/session/hierarchy/index.ts +1 -0
- package/src/session/hierarchy/session.ts +15 -3
- package/src/session/hierarchy/thread.ts +55 -0
- package/src/session/migration/id-prefix.ts +8 -13
- package/src/session/retention/__tests__/archive.test.ts +5 -3
- package/src/session/summary/__tests__/materialize.test.ts +6 -4
- package/src/store/index.ts +0 -3
- package/src/store/session/__tests__/disk.test.ts +57 -6
- package/src/store/session/__tests__/memory.test.ts +84 -9
- package/src/store/session/disk.ts +57 -1
- package/src/store/session/index.ts +3 -4
- package/src/store/session/memory.ts +13 -1
- package/src/store/thread/disk.ts +261 -0
- package/src/store/thread/index.ts +7 -0
- package/src/store/thread/memory.ts +104 -0
- package/src/types/agent/base.ts +17 -21
- package/src/types/agent/factory.ts +8 -3
- package/src/types/agent/task.ts +19 -11
- package/src/types/ids/index.ts +8 -15
- package/src/types/rag/retrieval.ts +4 -3
- package/src/types/run/config.ts +6 -5
- package/src/types/run/metadata.ts +5 -18
- package/src/types/session/ids.ts +4 -15
- package/src/types/session/index.ts +1 -2
- package/src/types/session/store.ts +34 -11
- package/src/types/thread/index.ts +5 -0
- package/src/types/thread/store.ts +92 -0
- package/src/utils/id.ts +3 -24
- package/dist/store/conversation/memory.d.ts +0 -43
- package/dist/store/conversation/memory.d.ts.map +0 -1
- package/dist/store/conversation/memory.js +0 -108
- package/dist/store/conversation/memory.js.map +0 -1
- package/dist/types/conversation/index.d.ts +0 -14
- package/dist/types/conversation/index.d.ts.map +0 -1
- package/dist/types/conversation/index.js +0 -2
- package/dist/types/conversation/index.js.map +0 -1
- package/src/store/conversation/memory.ts +0 -144
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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'
|