@namzu/sdk 13.0.0 → 14.0.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 +208 -0
- package/dist/agents/runAgent.d.ts +9 -0
- package/dist/agents/runAgent.d.ts.map +1 -1
- package/dist/agents/runAgent.js.map +1 -1
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js +101 -0
- package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js.map +1 -0
- package/dist/connector/mcp/http-sse.d.ts +10 -0
- package/dist/connector/mcp/http-sse.d.ts.map +1 -1
- package/dist/connector/mcp/http-sse.js +16 -0
- package/dist/connector/mcp/http-sse.js.map +1 -1
- package/dist/connector/mcp/stdio.d.ts +12 -0
- package/dist/connector/mcp/stdio.d.ts.map +1 -1
- package/dist/connector/mcp/stdio.js +33 -1
- package/dist/connector/mcp/stdio.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +2 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
- package/dist/connector/mcp/streamable-http.js +13 -1
- package/dist/connector/mcp/streamable-http.js.map +1 -1
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts +2 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts.map +1 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js +73 -0
- package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js.map +1 -0
- package/dist/contracts/schemas.d.ts +15 -0
- package/dist/contracts/schemas.d.ts.map +1 -1
- package/dist/contracts/schemas.js +18 -1
- package/dist/contracts/schemas.js.map +1 -1
- 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.map +1 -1
- package/dist/manager/agent/lifecycle.js +39 -4
- 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/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
- package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
- package/dist/manager/project/lifecycle.d.ts +66 -0
- package/dist/manager/project/lifecycle.d.ts.map +1 -0
- package/dist/manager/project/lifecycle.js +125 -0
- package/dist/manager/project/lifecycle.js.map +1 -0
- package/dist/public-runtime.d.ts +2 -2
- 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/errors.d.ts +71 -1
- package/dist/session/errors.d.ts.map +1 -1
- package/dist/session/errors.js +50 -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 +4 -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 +5 -4
- package/dist/session/handoff/single.js.map +1 -1
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
- package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
- package/dist/session/migration/filesystem.d.ts.map +1 -1
- package/dist/session/migration/filesystem.js +24 -0
- package/dist/session/migration/filesystem.js.map +1 -1
- 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 +6 -2
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +120 -3
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +6 -2
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +78 -3
- 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/project/entity.d.ts +17 -0
- package/dist/types/project/entity.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/agents/runAgent.ts +9 -0
- package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
- package/src/connector/mcp/http-sse.ts +17 -0
- package/src/connector/mcp/stdio.ts +34 -1
- package/src/connector/mcp/streamable-http.ts +15 -1
- package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
- package/src/contracts/schemas.ts +18 -1
- package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
- package/src/manager/agent/lifecycle.ts +42 -6
- package/src/manager/index.ts +3 -0
- package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
- package/src/manager/project/lifecycle.ts +160 -0
- package/src/public-runtime.ts +9 -0
- package/src/session/errors.ts +85 -1
- package/src/session/handoff/broadcast.ts +4 -5
- package/src/session/handoff/single.ts +5 -4
- package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
- package/src/session/migration/filesystem.ts +28 -0
- package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
- package/src/store/session/disk.ts +142 -4
- package/src/store/session/memory.ts +95 -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/project/entity.ts +18 -0
- package/src/types/session/store.ts +95 -1
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProjectManager — open/close a workspace, and the gate the ingress paths read.
|
|
3
|
+
*
|
|
4
|
+
* This is the Thread archival model moved to the level that survives. Thread
|
|
5
|
+
* carried `status` and `ThreadManager.requireOpen`; Project — the thing a
|
|
6
|
+
* tenant actually owns, configures, and closes — carried nothing, so archiving
|
|
7
|
+
* a workspace meant nothing to the code. See the hierarchy plan.
|
|
8
|
+
*
|
|
9
|
+
* Two deliberate differences from the Thread version:
|
|
10
|
+
*
|
|
11
|
+
* - Status moves **both ways**. A thread was archived forever; a workspace is
|
|
12
|
+
* long-lived and a mistaken close should be recoverable.
|
|
13
|
+
* - The gate is a **function over a store**, not a method on an injected
|
|
14
|
+
* manager. The three ingress paths already hold a `SessionStore`, so
|
|
15
|
+
* nothing has to be threaded through a constructor for the invariant to be
|
|
16
|
+
* enforced — a gate that requires new wiring is a gate somebody forgets to
|
|
17
|
+
* wire.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
PROJECT_NOT_EMPTY_SAMPLE_LIMIT,
|
|
22
|
+
ProjectClosedError,
|
|
23
|
+
ProjectNotEmptyError,
|
|
24
|
+
} from '../../session/errors.js'
|
|
25
|
+
import type { TenantId } from '../../types/ids/index.js'
|
|
26
|
+
import type { Project } from '../../types/project/entity.js'
|
|
27
|
+
import type { SessionStatus } from '../../types/session/entity.js'
|
|
28
|
+
import type { ProjectId } from '../../types/session/ids.js'
|
|
29
|
+
import type { SessionStore } from '../../types/session/store.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Session statuses that block a workspace from closing.
|
|
33
|
+
*
|
|
34
|
+
* A live session is a running agent. Closing its workspace out from under it
|
|
35
|
+
* would strand work whose owner is still watching, so the caller settles the
|
|
36
|
+
* sessions first and archival never cascades.
|
|
37
|
+
*/
|
|
38
|
+
const ARCHIVAL_BLOCKING_STATUSES: ReadonlySet<SessionStatus> = new Set([
|
|
39
|
+
'active',
|
|
40
|
+
'locked',
|
|
41
|
+
'awaiting_merge',
|
|
42
|
+
'awaiting_hitl',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load a Project and assert it accepts new work.
|
|
47
|
+
*
|
|
48
|
+
* Throws on absence — a missing project is a hard error, not an assumed-open
|
|
49
|
+
* one — and {@link ProjectClosedError} on an archived project. Returns the
|
|
50
|
+
* loaded Project so the caller can skip a second read; every current caller
|
|
51
|
+
* needs `config` immediately afterwards.
|
|
52
|
+
*
|
|
53
|
+
* `op` names the operation in the error, because "archived" is not by itself
|
|
54
|
+
* an explanation of what the caller was refused.
|
|
55
|
+
*/
|
|
56
|
+
export async function requireOpenProject(
|
|
57
|
+
store: Pick<SessionStore, 'getProject'>,
|
|
58
|
+
projectId: ProjectId,
|
|
59
|
+
tenantId: TenantId,
|
|
60
|
+
op: string,
|
|
61
|
+
): Promise<Project> {
|
|
62
|
+
const project = await store.getProject(projectId, tenantId)
|
|
63
|
+
if (!project) {
|
|
64
|
+
throw new Error(`Project ${projectId} not found for tenant ${tenantId} — ${op} rejected`)
|
|
65
|
+
}
|
|
66
|
+
if (project.status === 'archived') {
|
|
67
|
+
throw new ProjectClosedError({ projectId, op })
|
|
68
|
+
}
|
|
69
|
+
return project
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ProjectManagerDeps {
|
|
73
|
+
store: SessionStore
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class ProjectManager {
|
|
77
|
+
constructor(private readonly deps: ProjectManagerDeps) {}
|
|
78
|
+
|
|
79
|
+
/** See {@link requireOpenProject}. */
|
|
80
|
+
requireOpen(projectId: ProjectId, tenantId: TenantId, op = 'require-open'): Promise<Project> {
|
|
81
|
+
return requireOpenProject(this.deps.store, projectId, tenantId, op)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Close a workspace.
|
|
86
|
+
*
|
|
87
|
+
* Refuses while any attached session is non-terminal
|
|
88
|
+
* ({@link ARCHIVAL_BLOCKING_STATUSES}), throwing {@link ProjectNotEmptyError}
|
|
89
|
+
* with a sample of what is blocking. The presence check runs **before** the
|
|
90
|
+
* already-archived short-circuit, so a workspace that is archived and still
|
|
91
|
+
* harbouring a live session reports the live session rather than reporting
|
|
92
|
+
* success — the second call is where an operator finds out.
|
|
93
|
+
*
|
|
94
|
+
* Idempotent: re-archiving an empty archived project is a no-op that
|
|
95
|
+
* returns the stored record without a write, so it does not burn a version
|
|
96
|
+
* and cannot lose a race it is not in.
|
|
97
|
+
*/
|
|
98
|
+
async archive(projectId: ProjectId, tenantId: TenantId): Promise<Project> {
|
|
99
|
+
const project = await this.deps.store.getProject(projectId, tenantId)
|
|
100
|
+
if (!project) {
|
|
101
|
+
throw new Error(`Project ${projectId} not found for tenant ${tenantId} — archive rejected`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const sessions = (await this.deps.store.listSessionsByProject?.(projectId, tenantId)) ?? []
|
|
105
|
+
const blocking = sessions.filter((s) => ARCHIVAL_BLOCKING_STATUSES.has(s.status))
|
|
106
|
+
if (blocking.length > 0) {
|
|
107
|
+
throw new ProjectNotEmptyError({
|
|
108
|
+
projectId,
|
|
109
|
+
tenantId,
|
|
110
|
+
op: 'archive',
|
|
111
|
+
blockingSessions: blocking
|
|
112
|
+
.slice(0, PROJECT_NOT_EMPTY_SAMPLE_LIMIT)
|
|
113
|
+
.map((s) => ({ sessionId: s.id, status: s.status })),
|
|
114
|
+
totalBlockingSessions: blocking.length,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (project.status === 'archived') return project
|
|
119
|
+
return this.write(projectId, 'archived', tenantId, project.ownerVersion)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reopen a workspace. Idempotent in the same way, and unconditional
|
|
124
|
+
* otherwise: there is nothing a live session could make unsafe about
|
|
125
|
+
* accepting work again.
|
|
126
|
+
*/
|
|
127
|
+
async reopen(projectId: ProjectId, tenantId: TenantId): Promise<Project> {
|
|
128
|
+
const project = await this.deps.store.getProject(projectId, tenantId)
|
|
129
|
+
if (!project) {
|
|
130
|
+
throw new Error(`Project ${projectId} not found for tenant ${tenantId} — reopen rejected`)
|
|
131
|
+
}
|
|
132
|
+
if (project.status === 'open') return project
|
|
133
|
+
return this.write(projectId, 'open', tenantId, project.ownerVersion)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private async write(
|
|
137
|
+
projectId: ProjectId,
|
|
138
|
+
status: Project['status'],
|
|
139
|
+
tenantId: TenantId,
|
|
140
|
+
expectedOwnerVersion: number,
|
|
141
|
+
): Promise<Project> {
|
|
142
|
+
const setStatus = this.deps.store.setProjectStatus
|
|
143
|
+
if (!setStatus) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Project ${projectId} cannot be set to '${status}': this SessionStore does not implement setProjectStatus. A store without it cannot record the state, and pretending the write succeeded would leave the gate reading 'open' forever.`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const written = await setStatus.call(
|
|
149
|
+
this.deps.store,
|
|
150
|
+
projectId,
|
|
151
|
+
status,
|
|
152
|
+
tenantId,
|
|
153
|
+
expectedOwnerVersion,
|
|
154
|
+
)
|
|
155
|
+
if (!written) {
|
|
156
|
+
throw new Error(`Project ${projectId} disappeared during '${status}' write`)
|
|
157
|
+
}
|
|
158
|
+
return written
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/public-runtime.ts
CHANGED
|
@@ -225,6 +225,7 @@ export {
|
|
|
225
225
|
AgentManager,
|
|
226
226
|
EmergencySaveManager,
|
|
227
227
|
PlanManager,
|
|
228
|
+
ProjectManager,
|
|
228
229
|
RunPersistence,
|
|
229
230
|
ThreadManager,
|
|
230
231
|
} from './manager/index.js'
|
|
@@ -483,6 +484,14 @@ export {
|
|
|
483
484
|
|
|
484
485
|
export {
|
|
485
486
|
AncestryCycleError,
|
|
487
|
+
// Exported, unlike `ThreadClosedError`, because a host that closes a
|
|
488
|
+
// workspace has to be able to tell "this workspace is closed" from any
|
|
489
|
+
// other spawn failure — and matching on a message string is not a
|
|
490
|
+
// contract. The comment below is the reason; these are the first three
|
|
491
|
+
// that take it seriously.
|
|
492
|
+
ProjectClosedError,
|
|
493
|
+
ProjectNotEmptyError,
|
|
494
|
+
StaleProjectError,
|
|
486
495
|
// Exported with the CAS it announces. A host that opts into
|
|
487
496
|
// `expectedOwnerVersion` has to be able to tell "somebody else took this
|
|
488
497
|
// session" from any other failure, and string-matching a message is not a
|
package/src/session/errors.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { SessionId, TenantId } from '../types/ids/index.js'
|
|
11
11
|
import type { SessionStatus } from '../types/session/entity.js'
|
|
12
|
-
import type { ThreadId } from '../types/session/ids.js'
|
|
12
|
+
import type { ProjectId, ThreadId } from '../types/session/ids.js'
|
|
13
13
|
import type { WorkspaceBackendKind } from './workspace/driver.js'
|
|
14
14
|
|
|
15
15
|
/**
|
|
@@ -186,3 +186,87 @@ export class ThreadNotEmptyError extends Error {
|
|
|
186
186
|
this.details = details
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Raised when an ingress path is asked to attach work to an archived Project.
|
|
192
|
+
*
|
|
193
|
+
* The sibling of {@link ThreadClosedError}, on the level that survives. A
|
|
194
|
+
* closed workspace is a decision by its owner, and the paths that create
|
|
195
|
+
* sessions have to be able to see it — otherwise "archived" is a word in a
|
|
196
|
+
* listing rather than a state of the system.
|
|
197
|
+
*/
|
|
198
|
+
export class ProjectClosedError extends Error {
|
|
199
|
+
readonly details: {
|
|
200
|
+
projectId: ProjectId
|
|
201
|
+
op: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
constructor(details: { projectId: ProjectId; op: string }) {
|
|
205
|
+
super(`Project ${details.projectId} is archived; operation '${details.op}' rejected`)
|
|
206
|
+
this.name = 'ProjectClosedError'
|
|
207
|
+
this.details = details
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Raised by the project archive path when sessions are still attached and not
|
|
213
|
+
* in a terminal state.
|
|
214
|
+
*
|
|
215
|
+
* Archiving does not cascade and does not kill anything: a live session is a
|
|
216
|
+
* running agent, and closing its workspace out from under it would strand
|
|
217
|
+
* work whose owner is still watching. The caller settles the sessions first.
|
|
218
|
+
* `blockingSessions` is truncated to {@link PROJECT_NOT_EMPTY_SAMPLE_LIMIT};
|
|
219
|
+
* `totalBlockingSessions` is the real count.
|
|
220
|
+
*/
|
|
221
|
+
export const PROJECT_NOT_EMPTY_SAMPLE_LIMIT = 50
|
|
222
|
+
|
|
223
|
+
export class ProjectNotEmptyError extends Error {
|
|
224
|
+
readonly details: {
|
|
225
|
+
projectId: ProjectId
|
|
226
|
+
tenantId: TenantId
|
|
227
|
+
op: 'archive'
|
|
228
|
+
blockingSessions: ReadonlyArray<{ sessionId: SessionId; status: SessionStatus }>
|
|
229
|
+
totalBlockingSessions: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
constructor(details: {
|
|
233
|
+
projectId: ProjectId
|
|
234
|
+
tenantId: TenantId
|
|
235
|
+
op: 'archive'
|
|
236
|
+
blockingSessions: ReadonlyArray<{ sessionId: SessionId; status: SessionStatus }>
|
|
237
|
+
totalBlockingSessions: number
|
|
238
|
+
}) {
|
|
239
|
+
super(
|
|
240
|
+
`Project ${details.projectId} ${details.op} blocked: ${details.totalBlockingSessions} session(s) still attached`,
|
|
241
|
+
)
|
|
242
|
+
this.name = 'ProjectNotEmptyError'
|
|
243
|
+
this.details = details
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Raised when a project status write loses a compare-and-set.
|
|
249
|
+
*
|
|
250
|
+
* The sibling of {@link StaleSessionError}: the caller re-reads and decides
|
|
251
|
+
* again, because the project it was about to close is not the project on
|
|
252
|
+
* disk.
|
|
253
|
+
*/
|
|
254
|
+
export class StaleProjectError extends Error {
|
|
255
|
+
readonly details: {
|
|
256
|
+
projectId: ProjectId
|
|
257
|
+
expectedOwnerVersion: number
|
|
258
|
+
actualOwnerVersion: number
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
constructor(details: {
|
|
262
|
+
projectId: ProjectId
|
|
263
|
+
expectedOwnerVersion: number
|
|
264
|
+
actualOwnerVersion: number
|
|
265
|
+
}) {
|
|
266
|
+
super(
|
|
267
|
+
`Project ${details.projectId} was modified concurrently: expected ownerVersion ${details.expectedOwnerVersion}, found ${details.actualOwnerVersion}`,
|
|
268
|
+
)
|
|
269
|
+
this.name = 'StaleProjectError'
|
|
270
|
+
this.details = details
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { requireOpenProject } from '../../manager/project/lifecycle.js'
|
|
24
25
|
import type { ThreadManager } from '../../manager/thread/lifecycle.js'
|
|
25
26
|
import type { SessionId, TenantId } from '../../types/ids/index.js'
|
|
26
27
|
import type { Session } from '../../types/session/entity.js'
|
|
@@ -189,11 +190,9 @@ export async function executeBroadcastHandoff(
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
// 6. Capacity — width + depth. Width covers N new children in one shot
|
|
192
|
-
// (§6.5); depth covers `source.depth + 1 ≤ maxDepth`.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
throw new Error(`Project ${source.projectId} not found`)
|
|
196
|
-
}
|
|
193
|
+
// (§6.5); depth covers `source.depth + 1 ≤ maxDepth`. The same read is
|
|
194
|
+
// the archive gate: a closed workspace takes no broadcast either.
|
|
195
|
+
const project = await requireOpenProject(deps.store, source.projectId, tenantId, 'broadcast')
|
|
197
196
|
await deps.capacity.validateWidth(
|
|
198
197
|
source.id,
|
|
199
198
|
assignments.length,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* 9. Emit `onCommitted` with the new version.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { requireOpenProject } from '../../manager/project/lifecycle.js'
|
|
23
24
|
import type { ThreadManager } from '../../manager/thread/lifecycle.js'
|
|
24
25
|
import type { SessionId, TenantId } from '../../types/ids/index.js'
|
|
25
26
|
import type { Session } from '../../types/session/entity.js'
|
|
@@ -145,10 +146,10 @@ export async function executeSingleHandoff(
|
|
|
145
146
|
// ownership of the same session. Width only applies to broadcast).
|
|
146
147
|
// For `single` we still validate depth to future-proof should the flow
|
|
147
148
|
// evolve into a branch variant.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
// The same read is the archive gate: an archived workspace accepts no
|
|
150
|
+
// handoff either. Transferring ownership into a closed workspace would
|
|
151
|
+
// be a new owner for work its project says is finished.
|
|
152
|
+
const project = await requireOpenProject(deps.store, source.projectId, tenantId, 'handoff')
|
|
152
153
|
await deps.capacity.validateDepth(source.id, project.config.maxDelegationDepth, tenantId)
|
|
153
154
|
|
|
154
155
|
// 5. CAS → `idle → locked`.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } 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 { ProjectIdSchema } from '../../../contracts/schemas.js'
|
|
7
|
+
import { FilesystemMigrationError } from '../errors.js'
|
|
8
|
+
import { DefaultFilesystemMigrator, NOOP_FILESYSTEM_MIGRATION_SINK } from '../filesystem.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The v0.2.0 migration is the only place a `ProjectId` is built from data
|
|
12
|
+
* instead of generated: it takes a legacy `thd_*` folder name off disk and
|
|
13
|
+
* mints `prj_legacy_<that name>`. The only check the name had passed was
|
|
14
|
+
* `startsWith('thd_')`, so a folder named `thd_Not An Id` produced a project
|
|
15
|
+
* id that satisfies the TypeScript type, is written into the new layout as a
|
|
16
|
+
* directory name, and is rejected by the SDK's own `ProjectIdSchema`.
|
|
17
|
+
*
|
|
18
|
+
* It refuses such a folder rather than skipping it. Skipping would leave that
|
|
19
|
+
* thread's runs on disk and unaddressable, write the completion marker anyway,
|
|
20
|
+
* and return `kind: 'migrated'` with the thread missing from the list — the
|
|
21
|
+
* failure shape this codebase keeps meeting, where the run did not fail, it
|
|
22
|
+
* succeeded while quietly not doing what it said.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const dirs: string[] = []
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
28
|
+
dirs.length = 0
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
async function seed(root: string, folder: string): Promise<void> {
|
|
32
|
+
const dir = join(root, 'threads', folder, 'runs', 'run_seed')
|
|
33
|
+
await mkdir(dir, { recursive: true })
|
|
34
|
+
await writeFile(join(dir, 'run.json'), JSON.stringify({ id: 'run_seed' }), 'utf-8')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function newRoot(tag: string): Promise<string> {
|
|
38
|
+
const root = await mkdtemp(join(tmpdir(), `namzu-mig-${tag}-`))
|
|
39
|
+
dirs.push(root)
|
|
40
|
+
return root
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('the migration only mints project ids that are project ids', () => {
|
|
44
|
+
it('refuses a legacy folder whose name is not a thread id, and names it', async () => {
|
|
45
|
+
const root = await newRoot('badname')
|
|
46
|
+
await seed(root, 'thd_Not An Id')
|
|
47
|
+
|
|
48
|
+
const error = await new DefaultFilesystemMigrator(NOOP_FILESYSTEM_MIGRATION_SINK)
|
|
49
|
+
.migrate(root)
|
|
50
|
+
.then(
|
|
51
|
+
() => null,
|
|
52
|
+
(e: unknown) => e,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
expect(error).toBeInstanceOf(FilesystemMigrationError)
|
|
56
|
+
const details = (error as FilesystemMigrationError).details
|
|
57
|
+
expect(details.op).toBe('validate_thread_id')
|
|
58
|
+
// An operator has to be able to go and look at the folder.
|
|
59
|
+
expect(details.path).toContain('thd_Not An Id')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('refuses rather than reporting a migration that left a thread behind', async () => {
|
|
63
|
+
// The seductive alternative is `continue`. This pins why it is wrong:
|
|
64
|
+
// the good thread would migrate, the result would say `migrated`, and
|
|
65
|
+
// the bad one would be absent from `migratedThreads` with no error.
|
|
66
|
+
const root = await newRoot('partial')
|
|
67
|
+
await seed(root, 'thd_a1b2c3d4e5f6')
|
|
68
|
+
await seed(root, 'thd_UPPER')
|
|
69
|
+
|
|
70
|
+
await expect(
|
|
71
|
+
new DefaultFilesystemMigrator(NOOP_FILESYSTEM_MIGRATION_SINK).migrate(root),
|
|
72
|
+
).rejects.toBeInstanceOf(FilesystemMigrationError)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('still migrates a folder whose name is a thread id, and the id it mints validates', async () => {
|
|
76
|
+
// The guard must refuse only what is not an id. A guard that refuses
|
|
77
|
+
// everything passes the two cases above and breaks migration entirely.
|
|
78
|
+
const root = await newRoot('good')
|
|
79
|
+
await seed(root, 'thd_a1b2c3d4e5f6')
|
|
80
|
+
|
|
81
|
+
const result = await new DefaultFilesystemMigrator(NOOP_FILESYSTEM_MIGRATION_SINK).migrate(root)
|
|
82
|
+
|
|
83
|
+
expect(result.kind).toBe('migrated')
|
|
84
|
+
expect(result.migratedThreads.map((m) => m.legacyThreadId)).toEqual(['thd_a1b2c3d4e5f6'])
|
|
85
|
+
for (const { newProjectId } of result.migratedThreads) {
|
|
86
|
+
expect({ newProjectId, ok: ProjectIdSchema.safeParse(newProjectId).success }).toEqual({
|
|
87
|
+
newProjectId,
|
|
88
|
+
ok: true,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
@@ -248,6 +248,34 @@ export class DefaultFilesystemMigrator implements FilesystemMigrator {
|
|
|
248
248
|
|
|
249
249
|
const legacyThreadId = entry
|
|
250
250
|
const suffix = legacyThreadId.slice('thd_'.length)
|
|
251
|
+
|
|
252
|
+
// The suffix comes off the filesystem, and `startsWith('thd_')`
|
|
253
|
+
// is the only thing it has passed. A folder named `thd_Not An
|
|
254
|
+
// Id` mints `prj_legacy_Not An Id`: structurally a `ProjectId`,
|
|
255
|
+
// accepted by no validator, and now a directory name in the new
|
|
256
|
+
// layout. The migration is the one place a project id is built
|
|
257
|
+
// from data rather than generated, so it is the one place the
|
|
258
|
+
// id contract has to be checked.
|
|
259
|
+
//
|
|
260
|
+
// (Not a containment check — it cannot be one. `readdir` yields
|
|
261
|
+
// path components, which hold no separator, and `..` is never
|
|
262
|
+
// among them; `prj_legacy_..` is a literal name, not a parent
|
|
263
|
+
// reference. Nothing here can leave the root.)
|
|
264
|
+
//
|
|
265
|
+
// It refuses rather than skipping. A skipped folder is a legacy
|
|
266
|
+
// thread whose runs are still on disk and no longer addressable,
|
|
267
|
+
// with nothing in the result saying so — and the marker would be
|
|
268
|
+
// written as if the migration were complete.
|
|
269
|
+
if (!/^[a-z0-9]+$/.test(suffix)) {
|
|
270
|
+
throw new FilesystemMigrationError({
|
|
271
|
+
op: 'validate_thread_id',
|
|
272
|
+
path: join(threadsDir, legacyThreadId),
|
|
273
|
+
cause: new Error(
|
|
274
|
+
`Legacy thread folder '${legacyThreadId}' is not a thread id: expected 'thd_' followed by lowercase alphanumerics. It is refused rather than migrated because the name is used as a directory name.`,
|
|
275
|
+
),
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
|
|
251
279
|
const newProjectId = `${LEGACY_DEFAULT_PROJECT_PREFIX}${suffix}` as ProjectId
|
|
252
280
|
|
|
253
281
|
const legacyRunsDir = join(threadsDir, legacyThreadId, 'runs')
|
|
@@ -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
|
+
})
|