@namzu/sdk 12.2.0 → 13.1.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 +199 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts +2 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.d.ts.map +1 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js +55 -0
- package/dist/bridge/a2a/__tests__/project-is-the-a2a-context.test.js.map +1 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
- package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
- package/dist/manager/agent/lifecycle.d.ts +15 -0
- package/dist/manager/agent/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +103 -26
- package/dist/manager/agent/lifecycle.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +7 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts +21 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.d.ts.map +1 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js +205 -0
- package/dist/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.js.map +1 -0
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js +14 -5
- package/dist/session/__tests__/integration/handoff-single-e2e.test.js.map +1 -1
- package/dist/session/errors.d.ts +25 -0
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +21 -0
- package/dist/session/errors.js.map +1 -1
- package/dist/session/handoff/broadcast.d.ts.map +1 -1
- package/dist/session/handoff/broadcast.js +10 -5
- package/dist/session/handoff/broadcast.js.map +1 -1
- package/dist/session/handoff/single.d.ts.map +1 -1
- package/dist/session/handoff/single.js +27 -4
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js +79 -0
- package/dist/store/session/__tests__/a-session-has-one-writer.test.js.map +1 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
- package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
- package/dist/store/session/disk.d.ts +4 -2
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +71 -4
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +4 -2
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +53 -4
- package/dist/store/session/memory.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js +7 -0
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +6 -0
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/base.d.ts +22 -0
- package/dist/types/agent/base.d.ts.map +1 -1
- package/dist/types/session/store.d.ts +80 -1
- package/dist/types/session/store.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/bridge/a2a/__tests__/project-is-the-a2a-context.test.ts +66 -0
- package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
- package/src/manager/agent/lifecycle.ts +107 -26
- package/src/public-runtime.ts +6 -0
- package/src/session/__tests__/integration/a-successful-delegation-disposes-its-workspace.test.ts +270 -0
- package/src/session/__tests__/integration/handoff-single-e2e.test.ts +14 -5
- package/src/session/errors.ts +29 -0
- package/src/session/handoff/broadcast.ts +10 -5
- package/src/session/handoff/single.ts +27 -4
- package/src/store/session/__tests__/a-session-has-one-writer.test.ts +116 -0
- package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
- package/src/store/session/disk.ts +84 -4
- package/src/store/session/memory.ts +66 -4
- package/src/tools/coordinator/agent.ts +7 -0
- package/src/tools/coordinator/index.ts +6 -0
- package/src/types/agent/base.ts +23 -0
- package/src/types/session/store.ts +88 -1
package/src/session/errors.ts
CHANGED
|
@@ -94,6 +94,35 @@ export class StaleThreadError extends Error {
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Raised when a Session write names a version the store no longer has.
|
|
99
|
+
*
|
|
100
|
+
* The sibling of {@link StaleThreadError}, and it arrived much later: Thread
|
|
101
|
+
* had a working compare-and-set from the start while `Session.ownerVersion`
|
|
102
|
+
* was documented as a CAS counter that nothing enforced. Two concurrent
|
|
103
|
+
* handoffs could both pass, both provision a worktree, and one silently erase
|
|
104
|
+
* the other.
|
|
105
|
+
*
|
|
106
|
+
* `actualVersion` is what the store holds, not what the caller sent — the
|
|
107
|
+
* caller already knows what it sent, and the useful half of the answer is how
|
|
108
|
+
* far behind it is.
|
|
109
|
+
*/
|
|
110
|
+
export class StaleSessionError extends Error {
|
|
111
|
+
readonly details: {
|
|
112
|
+
sessionId: SessionId
|
|
113
|
+
expectedVersion: number
|
|
114
|
+
actualVersion: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
constructor(details: { sessionId: SessionId; expectedVersion: number; actualVersion: number }) {
|
|
118
|
+
super(
|
|
119
|
+
`Stale Session ${details.sessionId}: expected ownerVersion=${details.expectedVersion}, actual=${details.actualVersion}. Another writer took ownership; re-read the session before retrying.`,
|
|
120
|
+
)
|
|
121
|
+
this.name = 'StaleSessionError'
|
|
122
|
+
this.details = details
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
97
126
|
/**
|
|
98
127
|
* Raised by the spawn path (and any caller that enforces the open-thread
|
|
99
128
|
* precondition) when a Thread is in `'archived'` state and would-be mutations
|
|
@@ -211,8 +211,12 @@ export async function executeBroadcastHandoff(
|
|
|
211
211
|
})
|
|
212
212
|
}
|
|
213
213
|
|
|
214
|
-
|
|
215
|
-
|
|
214
|
+
// The lock BUMPS the version — see the same change in single.ts. Writing
|
|
215
|
+
// the lock at the version it read left the locked window invisible, so a
|
|
216
|
+
// second broadcast holding the same snapshot passed the check above and
|
|
217
|
+
// fanned out over a session that was already locked.
|
|
218
|
+
const locked: Session = { ...source, status: 'locked', ownerVersion: source.ownerVersion + 1 }
|
|
219
|
+
await deps.store.updateSession(locked, tenantId, first.expectedOwnerVersion)
|
|
216
220
|
emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
|
|
217
221
|
|
|
218
222
|
// 8. Fan-out provisioning. Track per-recipient partial state so rollback
|
|
@@ -269,12 +273,13 @@ export async function executeBroadcastHandoff(
|
|
|
269
273
|
|
|
270
274
|
// 9. Commit source: `locked → awaiting_merge` (§5.4 — broadcast source is
|
|
271
275
|
// not `idle` until all recipients terminalize; coordinator role).
|
|
276
|
+
// Keeps the lock's version: one handoff, one ownership change, one
|
|
277
|
+
// version. The bump moved to the lock where it is load-bearing.
|
|
272
278
|
const committed: Session = {
|
|
273
|
-
...
|
|
279
|
+
...locked,
|
|
274
280
|
status: 'awaiting_merge',
|
|
275
|
-
ownerVersion: source.ownerVersion + 1,
|
|
276
281
|
}
|
|
277
|
-
await deps.store.updateSession(committed, tenantId)
|
|
282
|
+
await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
|
|
278
283
|
|
|
279
284
|
emit(deps.events.onCommitted, {
|
|
280
285
|
sessionId: source.id,
|
|
@@ -160,11 +160,24 @@ export async function executeSingleHandoff(
|
|
|
160
160
|
})
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
// The lock BUMPS the version, and that is what makes it a lock.
|
|
164
|
+
//
|
|
165
|
+
// It used to write `status: 'locked'` at the version it read, so a second
|
|
166
|
+
// handoff that had read the same snapshot saw an unchanged `ownerVersion`,
|
|
167
|
+
// passed the check above, and locked the session a second time. Both then
|
|
168
|
+
// provisioned a worktree and one silently erased the other. The comparison
|
|
169
|
+
// above was against a value nothing had moved.
|
|
170
|
+
//
|
|
171
|
+
// Passing `expectedOwnerVersion` makes the store the arbiter rather than
|
|
172
|
+
// this function: the check above is a fast fail on a stale snapshot, and
|
|
173
|
+
// this is the one that cannot be raced, because the store compares against
|
|
174
|
+
// what it holds at the instant of the write.
|
|
163
175
|
const locked: Session = {
|
|
164
176
|
...source,
|
|
165
177
|
status: 'locked',
|
|
178
|
+
ownerVersion: source.ownerVersion + 1,
|
|
166
179
|
}
|
|
167
|
-
await deps.store.updateSession(locked, tenantId)
|
|
180
|
+
await deps.store.updateSession(locked, tenantId, assignment.expectedOwnerVersion)
|
|
168
181
|
emit(deps.events.onLocked, { sessionId: source.id, at: new Date() })
|
|
169
182
|
|
|
170
183
|
// 6. Provision recipient resources. Track partial state for rollback.
|
|
@@ -197,16 +210,26 @@ export async function executeSingleHandoff(
|
|
|
197
210
|
// 7. Commit source: `locked → idle` with appended actor + bumped version.
|
|
198
211
|
// The source transitions ownership to the recipient — the previous
|
|
199
212
|
// owner is permanently read-only (§6.1).
|
|
213
|
+
// Keeps the LOCK's version rather than taking a second one.
|
|
214
|
+
//
|
|
215
|
+
// One handoff is one ownership change, so it consumes one version. The
|
|
216
|
+
// bump moved to the lock — where it has to be, or the locked window is
|
|
217
|
+
// invisible to a concurrent reader — and the commit finishes the
|
|
218
|
+
// transition the lock reserved. A host reading `committedOwnerVersion`
|
|
219
|
+
// sees the same number it always did.
|
|
220
|
+
//
|
|
221
|
+
// The expected version is the lock's, so anything that wrote to this
|
|
222
|
+
// session between the lock and here loses to the store rather than
|
|
223
|
+
// being overwritten by us.
|
|
200
224
|
const committed: Session = {
|
|
201
|
-
...
|
|
225
|
+
...locked,
|
|
202
226
|
status: 'idle',
|
|
203
227
|
currentActor: assignment.recipientActor,
|
|
204
228
|
previousActors: source.currentActor
|
|
205
229
|
? [...source.previousActors, source.currentActor]
|
|
206
230
|
: [...source.previousActors],
|
|
207
|
-
ownerVersion: source.ownerVersion + 1,
|
|
208
231
|
}
|
|
209
|
-
await deps.store.updateSession(committed, tenantId)
|
|
232
|
+
await deps.store.updateSession(committed, tenantId, locked.ownerVersion)
|
|
210
233
|
|
|
211
234
|
emit(deps.events.onCommitted, {
|
|
212
235
|
sessionId: source.id,
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { StaleSessionError } from '../../../session/errors.js'
|
|
4
|
+
import type { TenantId } from '../../../types/ids/index.js'
|
|
5
|
+
import type { Session } from '../../../types/session/entity.js'
|
|
6
|
+
import { InMemorySessionStore } from '../memory.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `Session.ownerVersion` was documented as the CAS counter for handoff, and
|
|
10
|
+
* nothing enforced it.
|
|
11
|
+
*
|
|
12
|
+
* Both stores overwrote unconditionally, so the only check was in the handoff
|
|
13
|
+
* path — where it compared a snapshot read several awaits earlier against
|
|
14
|
+
* itself. Two concurrent handoffs on one idle session both passed, both
|
|
15
|
+
* provisioned a worktree, and one silently erased the other.
|
|
16
|
+
*
|
|
17
|
+
* The store is the arbiter now, because it is the only party that can compare
|
|
18
|
+
* against the value at the instant of the write.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const TENANT = 'tnt_cas' as TenantId
|
|
22
|
+
|
|
23
|
+
async function seed(): Promise<{ store: InMemorySessionStore; session: Session }> {
|
|
24
|
+
const store = new InMemorySessionStore()
|
|
25
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'cas' }, TENANT)
|
|
26
|
+
const session = await store.createSession(
|
|
27
|
+
{ threadId: 'thd_cas' as never, projectId: project.id, currentActor: null },
|
|
28
|
+
TENANT,
|
|
29
|
+
)
|
|
30
|
+
return { store, session }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('a session write can require that nobody else wrote first', () => {
|
|
34
|
+
it('accepts a write naming the version the store holds', async () => {
|
|
35
|
+
const { store, session } = await seed()
|
|
36
|
+
|
|
37
|
+
await store.updateSession({ ...session, status: 'locked' }, TENANT, session.ownerVersion)
|
|
38
|
+
|
|
39
|
+
expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('refuses a write naming a version that has moved on', async () => {
|
|
43
|
+
const { store, session } = await seed()
|
|
44
|
+
|
|
45
|
+
// Somebody else takes it first.
|
|
46
|
+
await store.updateSession({ ...session, ownerVersion: 1 }, TENANT, 0)
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
store.updateSession({ ...session, status: 'locked' }, TENANT, 0),
|
|
50
|
+
).rejects.toBeInstanceOf(StaleSessionError)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('reports what the store holds, not what the caller sent', async () => {
|
|
54
|
+
// The caller already knows what it sent; the useful half of the answer
|
|
55
|
+
// is how far behind it is.
|
|
56
|
+
const { store, session } = await seed()
|
|
57
|
+
await store.updateSession({ ...session, ownerVersion: 7 }, TENANT, 0)
|
|
58
|
+
|
|
59
|
+
await store
|
|
60
|
+
.updateSession({ ...session, status: 'locked' }, TENANT, 3)
|
|
61
|
+
.then(() => expect.unreachable('the stale write should have been refused'))
|
|
62
|
+
.catch((err: StaleSessionError) => {
|
|
63
|
+
expect(err.details.expectedVersion).toBe(3)
|
|
64
|
+
expect(err.details.actualVersion).toBe(7)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('compares against the STORED version, not the payload', async () => {
|
|
69
|
+
// The payload is the caller's own copy. Comparing it to itself is the
|
|
70
|
+
// check the handoff path was already making and getting nothing from,
|
|
71
|
+
// so a write whose payload agrees with itself must still be refused
|
|
72
|
+
// when the store has moved.
|
|
73
|
+
const { store, session } = await seed()
|
|
74
|
+
await store.updateSession({ ...session, ownerVersion: 5 }, TENANT, 0)
|
|
75
|
+
|
|
76
|
+
const selfConsistentButStale: Session = { ...session, ownerVersion: 2, status: 'locked' }
|
|
77
|
+
|
|
78
|
+
await expect(store.updateSession(selfConsistentButStale, TENANT, 2)).rejects.toBeInstanceOf(
|
|
79
|
+
StaleSessionError,
|
|
80
|
+
)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('writes unconditionally when no version is named', async () => {
|
|
84
|
+
// The compatibility promise: every caller that existed before this
|
|
85
|
+
// parameter behaves exactly as it did.
|
|
86
|
+
const { store, session } = await seed()
|
|
87
|
+
await store.updateSession({ ...session, ownerVersion: 9 }, TENANT, 0)
|
|
88
|
+
|
|
89
|
+
await store.updateSession({ ...session, status: 'locked' }, TENANT)
|
|
90
|
+
|
|
91
|
+
expect((await store.getSession(session.id, TENANT))?.status).toBe('locked')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('lets exactly one of two concurrent writers win', async () => {
|
|
95
|
+
// The defect, in the shape it actually took: two writers holding the
|
|
96
|
+
// same snapshot, racing for the same session.
|
|
97
|
+
const { store, session } = await seed()
|
|
98
|
+
const snapshot = session.ownerVersion
|
|
99
|
+
|
|
100
|
+
const results = await Promise.allSettled([
|
|
101
|
+
store.updateSession(
|
|
102
|
+
{ ...session, status: 'locked', ownerVersion: snapshot + 1 },
|
|
103
|
+
TENANT,
|
|
104
|
+
snapshot,
|
|
105
|
+
),
|
|
106
|
+
store.updateSession(
|
|
107
|
+
{ ...session, status: 'locked', ownerVersion: snapshot + 1 },
|
|
108
|
+
TENANT,
|
|
109
|
+
snapshot,
|
|
110
|
+
),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
|
114
|
+
expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { TenantIsolationError } from '../../../session/errors.js'
|
|
7
|
+
import type { TenantId } from '../../../types/ids/index.js'
|
|
8
|
+
import type { SessionStore } from '../../../types/session/store.js'
|
|
9
|
+
import { DiskSessionStore } from '../disk.js'
|
|
10
|
+
import { InMemorySessionStore } from '../memory.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every project in existence ran at depth 4 and width 8.
|
|
14
|
+
*
|
|
15
|
+
* The config was hardcoded identically in both stores, `CreateProjectParams`
|
|
16
|
+
* was `{tenantId, name}`, and there was no `updateProject`. So a tenant with
|
|
17
|
+
* several workspaces could not give them different limits — which is most of
|
|
18
|
+
* what having several workspaces is for.
|
|
19
|
+
*
|
|
20
|
+
* Only the two fields something READS are settable. `ProjectConfig` declares
|
|
21
|
+
* eight; five enforcement sites read two of them. Exposing the other six would
|
|
22
|
+
* make dead fields easier to set, and a host that configures a retention policy
|
|
23
|
+
* and gets no error believes retention is on. `maxInterventionDepth` looks like
|
|
24
|
+
* an exception and is not: its three apparent readers are all comments.
|
|
25
|
+
*
|
|
26
|
+
* Both stores are driven by the same cases, because a reference implementation
|
|
27
|
+
* that disagrees with the durable one is worse than having only one.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const TENANT = 'tnt_cfg' as TenantId
|
|
31
|
+
const OTHER = 'tnt_other' as TenantId
|
|
32
|
+
|
|
33
|
+
const dirs: string[] = []
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
36
|
+
dirs.length = 0
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
async function diskStore(): Promise<SessionStore> {
|
|
40
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'namzu-cfg-'))
|
|
41
|
+
dirs.push(rootDir)
|
|
42
|
+
return new DiskSessionStore({ rootDir })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const IMPLEMENTATIONS: ReadonlyArray<readonly [string, () => Promise<SessionStore>]> = [
|
|
46
|
+
['in memory', async () => new InMemorySessionStore()],
|
|
47
|
+
['on disk', diskStore],
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
describe.each(IMPLEMENTATIONS)('a workspace carries its own limits (%s)', (_name, build) => {
|
|
51
|
+
it('takes the limits it was created with', async () => {
|
|
52
|
+
const store = await build()
|
|
53
|
+
|
|
54
|
+
const project = await store.createProject(
|
|
55
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2, maxDelegationWidth: 3 } },
|
|
56
|
+
TENANT,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(project.config.maxDelegationDepth).toBe(2)
|
|
60
|
+
expect(project.config.maxDelegationWidth).toBe(3)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('keeps the defaults for anything not given', async () => {
|
|
64
|
+
const store = await build()
|
|
65
|
+
|
|
66
|
+
const project = await store.createProject(
|
|
67
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
|
|
68
|
+
TENANT,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
expect(project.config.maxDelegationDepth).toBe(2)
|
|
72
|
+
expect(project.config.maxDelegationWidth).toBe(8)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('lets two workspaces of one tenant differ, which is the point', async () => {
|
|
76
|
+
const store = await build()
|
|
77
|
+
|
|
78
|
+
const narrow = await store.createProject(
|
|
79
|
+
{ tenantId: TENANT, name: 'narrow', config: { maxDelegationWidth: 1 } },
|
|
80
|
+
TENANT,
|
|
81
|
+
)
|
|
82
|
+
const wide = await store.createProject(
|
|
83
|
+
{ tenantId: TENANT, name: 'wide', config: { maxDelegationWidth: 16 } },
|
|
84
|
+
TENANT,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
expect(narrow.config.maxDelegationWidth).toBe(1)
|
|
88
|
+
expect(wide.config.maxDelegationWidth).toBe(16)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('changes a limit after the fact, and the change is readable back', async () => {
|
|
92
|
+
const store = await build()
|
|
93
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
94
|
+
|
|
95
|
+
await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
|
|
96
|
+
|
|
97
|
+
expect((await store.getProject(project.id, TENANT))?.config.maxDelegationWidth).toBe(12)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('leaves a limit it was not asked to change', async () => {
|
|
101
|
+
// Per field, not whole-value: raising the width says nothing about the
|
|
102
|
+
// depth, and resetting it would be an answer to a question nobody asked.
|
|
103
|
+
const store = await build()
|
|
104
|
+
const project = await store.createProject(
|
|
105
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 2 } },
|
|
106
|
+
TENANT,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await store.updateProject?.(project.id, { maxDelegationWidth: 12 }, TENANT)
|
|
110
|
+
|
|
111
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
112
|
+
expect(reloaded?.config.maxDelegationDepth).toBe(2)
|
|
113
|
+
expect(reloaded?.config.maxDelegationWidth).toBe(12)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('treats an explicitly undefined limit as "leave it", not "clear it"', async () => {
|
|
117
|
+
// The case the per-field guard exists for, and the one a plain spread
|
|
118
|
+
// gets wrong: `{...config}` with an explicit `undefined` key writes the
|
|
119
|
+
// undefined through and erases a limit the caller never mentioned. A
|
|
120
|
+
// caller building an update object programmatically produces exactly
|
|
121
|
+
// this shape.
|
|
122
|
+
const store = await build()
|
|
123
|
+
const project = await store.createProject(
|
|
124
|
+
{ tenantId: TENANT, name: 'w', config: { maxDelegationDepth: 3 } },
|
|
125
|
+
TENANT,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
await store.updateProject?.(
|
|
129
|
+
project.id,
|
|
130
|
+
{ maxDelegationDepth: undefined, maxDelegationWidth: 12 },
|
|
131
|
+
TENANT,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
135
|
+
expect(reloaded?.config.maxDelegationDepth).toBe(3)
|
|
136
|
+
expect(reloaded?.config.maxDelegationWidth).toBe(12)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('lists what this tenant owns, oldest first', async () => {
|
|
140
|
+
// Eight, spaced past the millisecond `createdAt` is measured in, so this
|
|
141
|
+
// is an assertion about age rather than about the tie-break below. With
|
|
142
|
+
// the sort dropped the disk store returns them in directory order, which
|
|
143
|
+
// is id-ascending — it matches creation order once in 8!.
|
|
144
|
+
const store = await build()
|
|
145
|
+
const created = []
|
|
146
|
+
for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
|
|
147
|
+
created.push(await store.createProject({ tenantId: TENANT, name }, TENANT))
|
|
148
|
+
await new Promise((resolve) => setTimeout(resolve, 2))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const listed = await store.listProjects?.(TENANT)
|
|
152
|
+
|
|
153
|
+
expect(listed?.map((p) => p.id)).toEqual(created.map((p) => p.id))
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('stays a total order when several are created in the same millisecond', async () => {
|
|
157
|
+
// CI found this and a slower machine could not: on a fast filesystem
|
|
158
|
+
// projects routinely share a `createdAt`, and "oldest first" alone left
|
|
159
|
+
// the rest to `readdir`. A caller paginating a listing that reorders
|
|
160
|
+
// under it sees the same project twice and never sees another.
|
|
161
|
+
const store = await build()
|
|
162
|
+
for (const name of ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) {
|
|
163
|
+
await store.createProject({ tenantId: TENANT, name }, TENANT)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const listed = (await store.listProjects?.(TENANT)) ?? []
|
|
167
|
+
|
|
168
|
+
expect(listed).toHaveLength(8)
|
|
169
|
+
for (let i = 1; i < listed.length; i++) {
|
|
170
|
+
const before = listed[i - 1]
|
|
171
|
+
const after = listed[i]
|
|
172
|
+
if (before === undefined || after === undefined) throw new Error('short listing')
|
|
173
|
+
const olderFirst = before.createdAt.getTime() < after.createdAt.getTime()
|
|
174
|
+
const tieByName =
|
|
175
|
+
before.createdAt.getTime() === after.createdAt.getTime() && before.id < after.id
|
|
176
|
+
expect({ pair: [before.name, after.name], ordered: olderFirst || tieByName }).toEqual({
|
|
177
|
+
pair: [before.name, after.name],
|
|
178
|
+
ordered: true,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('omits another tenant from the listing rather than refusing', async () => {
|
|
184
|
+
// A listing is a question about what you own. Refusing would confirm
|
|
185
|
+
// that somebody else's project is there, which is the leak the tenant
|
|
186
|
+
// boundary exists to prevent.
|
|
187
|
+
const store = await build()
|
|
188
|
+
await store.createProject({ tenantId: TENANT, name: 'mine' }, TENANT)
|
|
189
|
+
await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
|
|
190
|
+
|
|
191
|
+
expect((await store.listProjects?.(TENANT))?.map((p) => p.name)).toEqual(['mine'])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('refuses to reconfigure another tenant project', async () => {
|
|
195
|
+
// Reading a listing is a question; writing to somebody else's workspace
|
|
196
|
+
// is not, so this one throws.
|
|
197
|
+
const store = await build()
|
|
198
|
+
const theirs = await store.createProject({ tenantId: OTHER, name: 'theirs' }, OTHER)
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
store.updateProject?.(theirs.id, { maxDelegationWidth: 99 }, TENANT),
|
|
202
|
+
).rejects.toBeInstanceOf(TenantIsolationError)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('returns null for a project that does not exist', async () => {
|
|
206
|
+
const store = await build()
|
|
207
|
+
|
|
208
|
+
expect(await store.updateProject?.('prj_missing' as never, {}, TENANT)).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { appendFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'
|
|
25
25
|
import { join } from 'node:path'
|
|
26
|
-
import { TenantIsolationError } from '../../session/errors.js'
|
|
26
|
+
import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
|
|
27
27
|
import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
|
|
28
28
|
import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
|
|
29
29
|
import type { Message } from '../../types/message/index.js'
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
CreateProjectParams,
|
|
36
36
|
CreateSessionParams,
|
|
37
37
|
CreateSubSessionParams,
|
|
38
|
+
ProjectConfigInput,
|
|
38
39
|
SessionStore,
|
|
39
40
|
SessionView,
|
|
40
41
|
} from '../../types/session/store.js'
|
|
@@ -193,8 +194,8 @@ export class DiskSessionStore implements SessionStore {
|
|
|
193
194
|
tenantId,
|
|
194
195
|
name: params.name,
|
|
195
196
|
config: {
|
|
196
|
-
maxDelegationDepth: 4,
|
|
197
|
-
maxDelegationWidth: 8,
|
|
197
|
+
maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
|
|
198
|
+
maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
|
|
198
199
|
maxInterventionDepth: 10,
|
|
199
200
|
},
|
|
200
201
|
createdAt: now,
|
|
@@ -215,6 +216,66 @@ export class DiskSessionStore implements SessionStore {
|
|
|
215
216
|
return deserializeProject(raw)
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
async updateProject(
|
|
220
|
+
projectId: ProjectId,
|
|
221
|
+
config: ProjectConfigInput,
|
|
222
|
+
tenantId: TenantId,
|
|
223
|
+
): Promise<Project | null> {
|
|
224
|
+
const existing = await this.getProject(projectId, tenantId)
|
|
225
|
+
if (!existing) return null
|
|
226
|
+
// Per field, like the in-memory store: an omitted limit is left alone
|
|
227
|
+
// rather than reset.
|
|
228
|
+
const project: Project = {
|
|
229
|
+
...existing,
|
|
230
|
+
config: {
|
|
231
|
+
...existing.config,
|
|
232
|
+
...(config.maxDelegationDepth !== undefined
|
|
233
|
+
? { maxDelegationDepth: config.maxDelegationDepth }
|
|
234
|
+
: {}),
|
|
235
|
+
...(config.maxDelegationWidth !== undefined
|
|
236
|
+
? { maxDelegationWidth: config.maxDelegationWidth }
|
|
237
|
+
: {}),
|
|
238
|
+
},
|
|
239
|
+
updatedAt: new Date(),
|
|
240
|
+
}
|
|
241
|
+
await atomicWriteJson(
|
|
242
|
+
join(this.projectDir(projectId), 'project.json'),
|
|
243
|
+
serializeProject(project),
|
|
244
|
+
)
|
|
245
|
+
return project
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
|
|
249
|
+
// Read from the directory rather than the lazily-built index: the index
|
|
250
|
+
// only knows about projects this instance has already touched, so a
|
|
251
|
+
// listing built from it would omit everything written by a previous
|
|
252
|
+
// process — which for a store whose whole point is durability is the
|
|
253
|
+
// wrong answer.
|
|
254
|
+
const projectsRoot = join(this.rootDir, 'projects')
|
|
255
|
+
let entries: string[]
|
|
256
|
+
try {
|
|
257
|
+
entries = await readdir(projectsRoot)
|
|
258
|
+
} catch {
|
|
259
|
+
return []
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const found: Project[] = []
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
const raw = await readJson<PersistedProject>(join(projectsRoot, entry, 'project.json'))
|
|
265
|
+
if (!raw) continue
|
|
266
|
+
// Another tenant's project is absent, not an error — a listing is a
|
|
267
|
+
// question about what you own, and refusing would leak that
|
|
268
|
+
// somebody else's project is there.
|
|
269
|
+
if (raw.tenantId !== tenantId) continue
|
|
270
|
+
found.push(deserializeProject(raw))
|
|
271
|
+
}
|
|
272
|
+
// Tie-broken by id — see the in-memory store. On a fast filesystem two
|
|
273
|
+
// projects share a millisecond routinely, and without this the order
|
|
274
|
+
// came from readdir.
|
|
275
|
+
found.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id))
|
|
276
|
+
return found
|
|
277
|
+
}
|
|
278
|
+
|
|
218
279
|
// Session CRUD ------------------------------------------------------------
|
|
219
280
|
|
|
220
281
|
async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
|
|
@@ -308,7 +369,11 @@ export class DiskSessionStore implements SessionStore {
|
|
|
308
369
|
return results
|
|
309
370
|
}
|
|
310
371
|
|
|
311
|
-
async updateSession(
|
|
372
|
+
async updateSession(
|
|
373
|
+
session: Session,
|
|
374
|
+
tenantId: TenantId,
|
|
375
|
+
expectedOwnerVersion?: number,
|
|
376
|
+
): Promise<void> {
|
|
312
377
|
const located = await this.locateSession(session.id)
|
|
313
378
|
if (!located) {
|
|
314
379
|
throw new Error(`Session ${session.id} not found`)
|
|
@@ -323,6 +388,21 @@ export class DiskSessionStore implements SessionStore {
|
|
|
323
388
|
if (existing) {
|
|
324
389
|
this.assertTenant(existing.tenantId, tenantId, `session(${session.id})`)
|
|
325
390
|
}
|
|
391
|
+
// Against what is on disk, not against the payload. The write itself is
|
|
392
|
+
// atomic; this read-compare-write is NOT a critical section, so two
|
|
393
|
+
// processes can still both pass — see the contract note on
|
|
394
|
+
// `SessionStore.updateSession`. In one process it is the real lock.
|
|
395
|
+
if (
|
|
396
|
+
expectedOwnerVersion !== undefined &&
|
|
397
|
+
existing !== null &&
|
|
398
|
+
existing.ownerVersion !== expectedOwnerVersion
|
|
399
|
+
) {
|
|
400
|
+
throw new StaleSessionError({
|
|
401
|
+
sessionId: session.id,
|
|
402
|
+
expectedVersion: expectedOwnerVersion,
|
|
403
|
+
actualVersion: existing.ownerVersion,
|
|
404
|
+
})
|
|
405
|
+
}
|
|
326
406
|
const updated: Session = { ...session, updatedAt: new Date() }
|
|
327
407
|
await atomicWriteJson(join(located.path, 'session.json'), serializeSession(updated))
|
|
328
408
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* (Convention #5 deny-by-default, session-hierarchy.md §12.2).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { TenantIsolationError } from '../../session/errors.js'
|
|
11
|
+
import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
|
|
12
12
|
import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
|
|
13
13
|
import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
|
|
14
14
|
import type { Message } from '../../types/message/index.js'
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
CreateProjectParams,
|
|
21
21
|
CreateSessionParams,
|
|
22
22
|
CreateSubSessionParams,
|
|
23
|
+
ProjectConfigInput,
|
|
23
24
|
SessionStore,
|
|
24
25
|
SessionView,
|
|
25
26
|
} from '../../types/session/store.js'
|
|
@@ -88,8 +89,8 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
88
89
|
tenantId,
|
|
89
90
|
name: params.name,
|
|
90
91
|
config: {
|
|
91
|
-
maxDelegationDepth: 4,
|
|
92
|
-
maxDelegationWidth: 8,
|
|
92
|
+
maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
|
|
93
|
+
maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
|
|
93
94
|
maxInterventionDepth: 10,
|
|
94
95
|
},
|
|
95
96
|
createdAt: now,
|
|
@@ -106,6 +107,49 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
106
107
|
return record.project
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
async updateProject(
|
|
111
|
+
projectId: ProjectId,
|
|
112
|
+
config: ProjectConfigInput,
|
|
113
|
+
tenantId: TenantId,
|
|
114
|
+
): Promise<Project | null> {
|
|
115
|
+
const record = this.projects.get(projectId)
|
|
116
|
+
if (!record) return null
|
|
117
|
+
this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
|
|
118
|
+
// Per field: an omitted limit is left alone rather than reset, because a
|
|
119
|
+
// caller raising the width is saying nothing about the depth.
|
|
120
|
+
const project: Project = {
|
|
121
|
+
...record.project,
|
|
122
|
+
config: {
|
|
123
|
+
...record.project.config,
|
|
124
|
+
...(config.maxDelegationDepth !== undefined
|
|
125
|
+
? { maxDelegationDepth: config.maxDelegationDepth }
|
|
126
|
+
: {}),
|
|
127
|
+
...(config.maxDelegationWidth !== undefined
|
|
128
|
+
? { maxDelegationWidth: config.maxDelegationWidth }
|
|
129
|
+
: {}),
|
|
130
|
+
},
|
|
131
|
+
updatedAt: new Date(),
|
|
132
|
+
}
|
|
133
|
+
this.projects.set(projectId, { tenantId, project })
|
|
134
|
+
return project
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
|
|
138
|
+
const matches: Project[] = []
|
|
139
|
+
for (const record of this.projects.values()) {
|
|
140
|
+
if (record.tenantId !== tenantId) continue
|
|
141
|
+
matches.push(record.project)
|
|
142
|
+
}
|
|
143
|
+
// Tie-broken by id, because two projects created in the same
|
|
144
|
+
// millisecond otherwise fall back to insertion or directory order and
|
|
145
|
+
// "oldest first" stops being a total order. A caller paginating a
|
|
146
|
+
// listing that reorders under it sees items move between pages.
|
|
147
|
+
matches.sort(
|
|
148
|
+
(a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
|
|
149
|
+
)
|
|
150
|
+
return matches
|
|
151
|
+
}
|
|
152
|
+
|
|
109
153
|
// Session CRUD ------------------------------------------------------------
|
|
110
154
|
|
|
111
155
|
async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
|
|
@@ -151,7 +195,11 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
151
195
|
return matches
|
|
152
196
|
}
|
|
153
197
|
|
|
154
|
-
async updateSession(
|
|
198
|
+
async updateSession(
|
|
199
|
+
session: Session,
|
|
200
|
+
tenantId: TenantId,
|
|
201
|
+
expectedOwnerVersion?: number,
|
|
202
|
+
): Promise<void> {
|
|
155
203
|
const record = this.sessions.get(session.id)
|
|
156
204
|
if (!record) {
|
|
157
205
|
throw new Error(`Session ${session.id} not found`)
|
|
@@ -163,6 +211,20 @@ export class InMemorySessionStore implements SessionStore {
|
|
|
163
211
|
resource: `session(${session.id}) payload`,
|
|
164
212
|
})
|
|
165
213
|
}
|
|
214
|
+
// Compared against the STORED version, not against `session.ownerVersion`
|
|
215
|
+
// — the payload is the caller's copy, and comparing it to itself is
|
|
216
|
+
// precisely the check the handoff path was already making and getting
|
|
217
|
+
// nothing from.
|
|
218
|
+
if (
|
|
219
|
+
expectedOwnerVersion !== undefined &&
|
|
220
|
+
record.session.ownerVersion !== expectedOwnerVersion
|
|
221
|
+
) {
|
|
222
|
+
throw new StaleSessionError({
|
|
223
|
+
sessionId: session.id,
|
|
224
|
+
expectedVersion: expectedOwnerVersion,
|
|
225
|
+
actualVersion: record.session.ownerVersion,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
166
228
|
this.sessions.set(session.id, { tenantId, session: { ...session, updatedAt: new Date() } })
|
|
167
229
|
}
|
|
168
230
|
|