@namzu/sdk 13.1.0 → 14.0.1
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 +140 -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/lifecycle.d.ts.map +1 -1
- package/dist/manager/agent/lifecycle.js +6 -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 +12 -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/disk.d.ts +3 -1
- package/dist/store/session/disk.d.ts.map +1 -1
- package/dist/store/session/disk.js +64 -1
- package/dist/store/session/disk.js.map +1 -1
- package/dist/store/session/memory.d.ts +3 -1
- package/dist/store/session/memory.d.ts.map +1 -1
- package/dist/store/session/memory.js +39 -1
- package/dist/store/session/memory.js.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 +28 -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/lifecycle.ts +6 -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 +14 -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/disk.ts +79 -2
- package/src/store/session/memory.ts +49 -2
- package/src/types/project/entity.ts +18 -0
- package/src/types/session/store.ts +35 -1
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { mkdtemp, readFile, 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 { EMPTY_TOKEN_USAGE } from '../../../constants/limits.js'
|
|
7
|
+
import { AgentRegistry } from '../../../registry/agent/definitions.js'
|
|
8
|
+
import {
|
|
9
|
+
ProjectClosedError,
|
|
10
|
+
ProjectNotEmptyError,
|
|
11
|
+
StaleProjectError,
|
|
12
|
+
} from '../../../session/errors.js'
|
|
13
|
+
import { DefaultCapacityValidator } from '../../../session/handoff/capacity.js'
|
|
14
|
+
import { SessionSummaryMaterializer } from '../../../session/summary/materialize.js'
|
|
15
|
+
import { WorkspaceBackendRegistry } from '../../../session/workspace/registry.js'
|
|
16
|
+
import { DiskSessionStore } from '../../../store/session/disk.js'
|
|
17
|
+
import { InMemorySessionStore } from '../../../store/session/memory.js'
|
|
18
|
+
import { InMemoryThreadStore } from '../../../store/thread/memory.js'
|
|
19
|
+
import type { BaseAgentConfig, BaseAgentResult } from '../../../types/agent/base.js'
|
|
20
|
+
import type { Agent } from '../../../types/agent/core.js'
|
|
21
|
+
import type { AgentDefinition } from '../../../types/agent/factory.js'
|
|
22
|
+
import type { AgentTaskContext } from '../../../types/agent/task.js'
|
|
23
|
+
import type { AgentId, TenantId } from '../../../types/ids/index.js'
|
|
24
|
+
import type { SummaryId } from '../../../types/session/ids.js'
|
|
25
|
+
import { ZERO_COST } from '../../../utils/cost.js'
|
|
26
|
+
import { AgentManager } from '../../agent/lifecycle.js'
|
|
27
|
+
import { ThreadManager } from '../../thread/lifecycle.js'
|
|
28
|
+
import { ProjectManager } from '../lifecycle.js'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Archiving a workspace meant nothing to the code.
|
|
32
|
+
*
|
|
33
|
+
* `Thread` carried a status and a gate; `Project` — the thing a tenant owns,
|
|
34
|
+
* configures, gives an environment, and actually closes — carried neither. So
|
|
35
|
+
* a host could archive a workspace and the kernel would keep spawning agents
|
|
36
|
+
* into it, because there was no state for the spawn path to read.
|
|
37
|
+
*
|
|
38
|
+
* The spawn case drives the real `AgentManager` rather than calling the gate
|
|
39
|
+
* directly. A test that calls `requireOpenProject` proves the function throws;
|
|
40
|
+
* what has to hold is that the spawn path reaches it, and the only assertion
|
|
41
|
+
* that cannot pass with the call deleted is one made through the front door.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
const TENANT = 'tnt_close' as TenantId
|
|
45
|
+
|
|
46
|
+
function silentAgent(): Agent<BaseAgentConfig, BaseAgentResult> {
|
|
47
|
+
return {
|
|
48
|
+
type: 'reactive',
|
|
49
|
+
metadata: {
|
|
50
|
+
id: 'worker',
|
|
51
|
+
name: 'worker',
|
|
52
|
+
version: '1.0.0',
|
|
53
|
+
category: 'general',
|
|
54
|
+
description: 'does nothing',
|
|
55
|
+
type: 'reactive',
|
|
56
|
+
capabilities: {},
|
|
57
|
+
},
|
|
58
|
+
async run(): Promise<BaseAgentResult> {
|
|
59
|
+
return {
|
|
60
|
+
runId: 'run_child',
|
|
61
|
+
status: 'completed',
|
|
62
|
+
result: 'ok',
|
|
63
|
+
usage: { ...EMPTY_TOKEN_USAGE },
|
|
64
|
+
cost: { ...ZERO_COST },
|
|
65
|
+
iterations: 1,
|
|
66
|
+
durationMs: 0,
|
|
67
|
+
messages: [],
|
|
68
|
+
} as BaseAgentResult
|
|
69
|
+
},
|
|
70
|
+
async cancel() {},
|
|
71
|
+
getCapabilities() {
|
|
72
|
+
return {} as never
|
|
73
|
+
},
|
|
74
|
+
} as unknown as Agent<BaseAgentConfig, BaseAgentResult>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function definition(): AgentDefinition {
|
|
78
|
+
return {
|
|
79
|
+
info: {
|
|
80
|
+
id: 'worker',
|
|
81
|
+
name: 'worker',
|
|
82
|
+
version: '1.0.0',
|
|
83
|
+
category: 'general',
|
|
84
|
+
description: 'a worker',
|
|
85
|
+
tools: [],
|
|
86
|
+
defaults: { model: 'test', tokenBudget: 1_000 },
|
|
87
|
+
},
|
|
88
|
+
typedAgent: silentAgent(),
|
|
89
|
+
} as AgentDefinition
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A parent session in a real workspace, plus the manager that spawns into it. */
|
|
93
|
+
async function harness() {
|
|
94
|
+
const store = new InMemorySessionStore()
|
|
95
|
+
const threadStore = new InMemoryThreadStore()
|
|
96
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
97
|
+
const thread = await threadStore.createThread({ projectId: project.id, title: 't' }, TENANT)
|
|
98
|
+
const parentActor = { kind: 'agent', agentId: 'sup' as AgentId, tenantId: TENANT } as const
|
|
99
|
+
const parentSession = await store.createSession(
|
|
100
|
+
{ threadId: thread.id, projectId: project.id, currentActor: parentActor },
|
|
101
|
+
TENANT,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const registry = new AgentRegistry()
|
|
105
|
+
registry.register(definition())
|
|
106
|
+
|
|
107
|
+
let n = 0
|
|
108
|
+
const manager = new AgentManager(registry, undefined, {
|
|
109
|
+
sessionStore: store,
|
|
110
|
+
threadManager: new ThreadManager({ threadStore, sessionStore: store }),
|
|
111
|
+
workspaceRegistry: new WorkspaceBackendRegistry(),
|
|
112
|
+
capacity: new DefaultCapacityValidator(store),
|
|
113
|
+
summaryMaterializer: new SessionSummaryMaterializer({
|
|
114
|
+
store,
|
|
115
|
+
generateSummaryId: () => `sum_${++n}` as SummaryId,
|
|
116
|
+
}),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const context = {
|
|
120
|
+
parentRunId: 'run_parent' as never,
|
|
121
|
+
parentAgentId: 'sup',
|
|
122
|
+
parentAbortController: new AbortController(),
|
|
123
|
+
depth: 0,
|
|
124
|
+
budgetTracker: { total: 100_000, remaining: 100_000 },
|
|
125
|
+
tenantId: TENANT,
|
|
126
|
+
threadId: thread.id,
|
|
127
|
+
sessionId: parentSession.id,
|
|
128
|
+
projectId: project.id,
|
|
129
|
+
parentActor,
|
|
130
|
+
} as AgentTaskContext
|
|
131
|
+
|
|
132
|
+
const spawn = () =>
|
|
133
|
+
manager.sendMessage(
|
|
134
|
+
{
|
|
135
|
+
agentId: 'worker',
|
|
136
|
+
input: { messages: [], workingDirectory: '/tmp' } as never,
|
|
137
|
+
parentSessionId: parentSession.id,
|
|
138
|
+
tenantId: TENANT,
|
|
139
|
+
} as never,
|
|
140
|
+
context,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return { store, project, parentSession, projects: new ProjectManager({ store }), spawn }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
describe('a closed workspace takes no new work', () => {
|
|
147
|
+
it('spawns while the workspace is open', async () => {
|
|
148
|
+
// The control. Without it, every assertion below could be passing
|
|
149
|
+
// because the harness cannot spawn at all.
|
|
150
|
+
const h = await harness()
|
|
151
|
+
|
|
152
|
+
await expect(h.spawn()).resolves.toBeDefined()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('refuses a spawn into an archived workspace, through the real spawn path', async () => {
|
|
156
|
+
const h = await harness()
|
|
157
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
158
|
+
|
|
159
|
+
await expect(h.spawn()).rejects.toBeInstanceOf(ProjectClosedError)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('names the operation it refused', async () => {
|
|
163
|
+
// "Archived" alone does not tell a caller what they were denied.
|
|
164
|
+
const h = await harness()
|
|
165
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
166
|
+
|
|
167
|
+
const error = await h.spawn().then(
|
|
168
|
+
() => null,
|
|
169
|
+
(e: unknown) => e,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
expect((error as ProjectClosedError).details).toEqual({
|
|
173
|
+
projectId: h.project.id,
|
|
174
|
+
op: 'spawn',
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('spawns again after the workspace is reopened', async () => {
|
|
179
|
+
// Closing has to be reversible, or a mistaken archive is permanent.
|
|
180
|
+
const h = await harness()
|
|
181
|
+
await h.projects.archive(h.project.id, TENANT)
|
|
182
|
+
await h.projects.reopen(h.project.id, TENANT)
|
|
183
|
+
|
|
184
|
+
await expect(h.spawn()).resolves.toBeDefined()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('refuses to close a workspace with a live session, and says which', async () => {
|
|
188
|
+
// Archival does not cascade and does not kill: a live session is a
|
|
189
|
+
// running agent whose owner is still watching.
|
|
190
|
+
const h = await harness()
|
|
191
|
+
await h.store.updateSession({ ...h.parentSession, status: 'active' }, TENANT)
|
|
192
|
+
|
|
193
|
+
const error = await h.projects.archive(h.project.id, TENANT).then(
|
|
194
|
+
() => null,
|
|
195
|
+
(e: unknown) => e,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
expect(error).toBeInstanceOf(ProjectNotEmptyError)
|
|
199
|
+
const details = (error as ProjectNotEmptyError).details
|
|
200
|
+
expect(details.totalBlockingSessions).toBe(1)
|
|
201
|
+
expect(details.blockingSessions).toEqual([{ sessionId: h.parentSession.id, status: 'active' }])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('closes a workspace whose sessions have settled', async () => {
|
|
205
|
+
const h = await harness()
|
|
206
|
+
|
|
207
|
+
const archived = await h.projects.archive(h.project.id, TENANT)
|
|
208
|
+
|
|
209
|
+
expect(archived.status).toBe('archived')
|
|
210
|
+
expect((await h.store.getProject(h.project.id, TENANT))?.status).toBe('archived')
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('re-archiving is a no-op that does not burn a version', async () => {
|
|
214
|
+
// An idempotent call that bumped the counter would invalidate a version
|
|
215
|
+
// a concurrent caller is holding, turning a retry into a conflict.
|
|
216
|
+
const h = await harness()
|
|
217
|
+
const first = await h.projects.archive(h.project.id, TENANT)
|
|
218
|
+
|
|
219
|
+
const second = await h.projects.archive(h.project.id, TENANT)
|
|
220
|
+
|
|
221
|
+
expect(second.ownerVersion).toBe(first.ownerVersion)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('loses the second of two writes that read the same version', async () => {
|
|
225
|
+
// The store compares against what it holds, not against the caller's
|
|
226
|
+
// copy of it — the mistake the session CAS was written with first.
|
|
227
|
+
const h = await harness()
|
|
228
|
+
const read = await h.store.getProject(h.project.id, TENANT)
|
|
229
|
+
if (!read) throw new Error('project vanished')
|
|
230
|
+
|
|
231
|
+
await h.store.setProjectStatus?.(h.project.id, 'archived', TENANT, read.ownerVersion)
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
h.store.setProjectStatus?.(h.project.id, 'open', TENANT, read.ownerVersion),
|
|
235
|
+
).rejects.toBeInstanceOf(StaleProjectError)
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('a workspace stored before it had a status', () => {
|
|
240
|
+
const dirs: string[] = []
|
|
241
|
+
afterEach(async () => {
|
|
242
|
+
await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true })))
|
|
243
|
+
dirs.length = 0
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('reads as open at version 0, and can still be closed', async () => {
|
|
247
|
+
// Every project.json on disk today lacks both fields. Reading them as
|
|
248
|
+
// `undefined` would be worse than a wrong default: the compare-and-set
|
|
249
|
+
// would never match, so an existing workspace could never be closed.
|
|
250
|
+
const root = await mkdtemp(join(tmpdir(), 'namzu-oldproj-'))
|
|
251
|
+
dirs.push(root)
|
|
252
|
+
const store = new DiskSessionStore({ rootDir: root })
|
|
253
|
+
const project = await store.createProject({ tenantId: TENANT, name: 'w' }, TENANT)
|
|
254
|
+
|
|
255
|
+
const file = join(root, 'projects', project.id, 'project.json')
|
|
256
|
+
const raw = JSON.parse(await readFile(file, 'utf-8'))
|
|
257
|
+
raw.status = undefined
|
|
258
|
+
raw.ownerVersion = undefined
|
|
259
|
+
await writeFile(file, JSON.stringify(raw), 'utf-8')
|
|
260
|
+
|
|
261
|
+
const reloaded = await store.getProject(project.id, TENANT)
|
|
262
|
+
expect(reloaded?.status).toBe('open')
|
|
263
|
+
expect(reloaded?.ownerVersion).toBe(0)
|
|
264
|
+
|
|
265
|
+
const archived = await new ProjectManager({ store }).archive(project.id, TENANT)
|
|
266
|
+
expect(archived.status).toBe('archived')
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -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,12 @@ export {
|
|
|
225
225
|
AgentManager,
|
|
226
226
|
EmergencySaveManager,
|
|
227
227
|
PlanManager,
|
|
228
|
+
ProjectManager,
|
|
229
|
+
// The gate itself, not only the manager that wraps it. A host writing its
|
|
230
|
+
// own ingress path — a custom handoff, a queue consumer that creates
|
|
231
|
+
// sessions — needs to refuse a closed workspace without constructing a
|
|
232
|
+
// manager, which is the reason it is a function over a store.
|
|
233
|
+
requireOpenProject,
|
|
228
234
|
RunPersistence,
|
|
229
235
|
ThreadManager,
|
|
230
236
|
} from './manager/index.js'
|
|
@@ -483,6 +489,14 @@ export {
|
|
|
483
489
|
|
|
484
490
|
export {
|
|
485
491
|
AncestryCycleError,
|
|
492
|
+
// Exported, unlike `ThreadClosedError`, because a host that closes a
|
|
493
|
+
// workspace has to be able to tell "this workspace is closed" from any
|
|
494
|
+
// other spawn failure — and matching on a message string is not a
|
|
495
|
+
// contract. The comment below is the reason; these are the first three
|
|
496
|
+
// that take it seriously.
|
|
497
|
+
ProjectClosedError,
|
|
498
|
+
ProjectNotEmptyError,
|
|
499
|
+
StaleProjectError,
|
|
486
500
|
// Exported with the CAS it announces. A host that opts into
|
|
487
501
|
// `expectedOwnerVersion` has to be able to tell "somebody else took this
|
|
488
502
|
// 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
|
+
})
|