@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/dist/agents/runAgent.d.ts +9 -0
  3. package/dist/agents/runAgent.d.ts.map +1 -1
  4. package/dist/agents/runAgent.js.map +1 -1
  5. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts +2 -0
  6. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.d.ts.map +1 -0
  7. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js +101 -0
  8. package/dist/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.js.map +1 -0
  9. package/dist/connector/mcp/http-sse.d.ts +10 -0
  10. package/dist/connector/mcp/http-sse.d.ts.map +1 -1
  11. package/dist/connector/mcp/http-sse.js +16 -0
  12. package/dist/connector/mcp/http-sse.js.map +1 -1
  13. package/dist/connector/mcp/stdio.d.ts +12 -0
  14. package/dist/connector/mcp/stdio.d.ts.map +1 -1
  15. package/dist/connector/mcp/stdio.js +33 -1
  16. package/dist/connector/mcp/stdio.js.map +1 -1
  17. package/dist/connector/mcp/streamable-http.d.ts +2 -0
  18. package/dist/connector/mcp/streamable-http.d.ts.map +1 -1
  19. package/dist/connector/mcp/streamable-http.js +13 -1
  20. package/dist/connector/mcp/streamable-http.js.map +1 -1
  21. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts +2 -0
  22. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.d.ts.map +1 -0
  23. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js +73 -0
  24. package/dist/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.js.map +1 -0
  25. package/dist/contracts/schemas.d.ts +15 -0
  26. package/dist/contracts/schemas.d.ts.map +1 -1
  27. package/dist/contracts/schemas.js +18 -1
  28. package/dist/contracts/schemas.js.map +1 -1
  29. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  30. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  31. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  32. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  33. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  34. package/dist/manager/agent/lifecycle.js +39 -4
  35. package/dist/manager/agent/lifecycle.js.map +1 -1
  36. package/dist/manager/index.d.ts +2 -0
  37. package/dist/manager/index.d.ts.map +1 -1
  38. package/dist/manager/index.js +1 -0
  39. package/dist/manager/index.js.map +1 -1
  40. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
  41. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
  42. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
  43. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
  44. package/dist/manager/project/lifecycle.d.ts +66 -0
  45. package/dist/manager/project/lifecycle.d.ts.map +1 -0
  46. package/dist/manager/project/lifecycle.js +125 -0
  47. package/dist/manager/project/lifecycle.js.map +1 -0
  48. package/dist/public-runtime.d.ts +2 -2
  49. package/dist/public-runtime.d.ts.map +1 -1
  50. package/dist/public-runtime.js +7 -1
  51. package/dist/public-runtime.js.map +1 -1
  52. package/dist/session/errors.d.ts +71 -1
  53. package/dist/session/errors.d.ts.map +1 -1
  54. package/dist/session/errors.js +50 -0
  55. package/dist/session/errors.js.map +1 -1
  56. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  57. package/dist/session/handoff/broadcast.js +4 -5
  58. package/dist/session/handoff/broadcast.js.map +1 -1
  59. package/dist/session/handoff/single.d.ts.map +1 -1
  60. package/dist/session/handoff/single.js +5 -4
  61. package/dist/session/handoff/single.js.map +1 -1
  62. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
  63. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
  64. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
  65. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
  66. package/dist/session/migration/filesystem.d.ts.map +1 -1
  67. package/dist/session/migration/filesystem.js +24 -0
  68. package/dist/session/migration/filesystem.js.map +1 -1
  69. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  70. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  71. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  72. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  73. package/dist/store/session/disk.d.ts +6 -2
  74. package/dist/store/session/disk.d.ts.map +1 -1
  75. package/dist/store/session/disk.js +120 -3
  76. package/dist/store/session/disk.js.map +1 -1
  77. package/dist/store/session/memory.d.ts +6 -2
  78. package/dist/store/session/memory.d.ts.map +1 -1
  79. package/dist/store/session/memory.js +78 -3
  80. package/dist/store/session/memory.js.map +1 -1
  81. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  82. package/dist/tools/coordinator/agent.js +7 -0
  83. package/dist/tools/coordinator/agent.js.map +1 -1
  84. package/dist/tools/coordinator/index.d.ts.map +1 -1
  85. package/dist/tools/coordinator/index.js +6 -0
  86. package/dist/tools/coordinator/index.js.map +1 -1
  87. package/dist/types/agent/base.d.ts +22 -0
  88. package/dist/types/agent/base.d.ts.map +1 -1
  89. package/dist/types/project/entity.d.ts +17 -0
  90. package/dist/types/project/entity.d.ts.map +1 -1
  91. package/dist/types/session/store.d.ts +80 -1
  92. package/dist/types/session/store.d.ts.map +1 -1
  93. package/package.json +1 -1
  94. package/src/agents/runAgent.ts +9 -0
  95. package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
  96. package/src/connector/mcp/http-sse.ts +17 -0
  97. package/src/connector/mcp/stdio.ts +34 -1
  98. package/src/connector/mcp/streamable-http.ts +15 -1
  99. package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
  100. package/src/contracts/schemas.ts +18 -1
  101. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  102. package/src/manager/agent/lifecycle.ts +42 -6
  103. package/src/manager/index.ts +3 -0
  104. package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
  105. package/src/manager/project/lifecycle.ts +160 -0
  106. package/src/public-runtime.ts +9 -0
  107. package/src/session/errors.ts +85 -1
  108. package/src/session/handoff/broadcast.ts +4 -5
  109. package/src/session/handoff/single.ts +5 -4
  110. package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
  111. package/src/session/migration/filesystem.ts +28 -0
  112. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  113. package/src/store/session/disk.ts +142 -4
  114. package/src/store/session/memory.ts +95 -4
  115. package/src/tools/coordinator/agent.ts +7 -0
  116. package/src/tools/coordinator/index.ts +6 -0
  117. package/src/types/agent/base.ts +23 -0
  118. package/src/types/project/entity.ts +18 -0
  119. 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
+ }
@@ -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
@@ -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
- const project = await deps.store.getProject(source.projectId, tenantId)
194
- if (!project) {
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
- const project = await deps.store.getProject(source.projectId, tenantId)
149
- if (!project) {
150
- throw new Error(`Project ${source.projectId} not found`)
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
+ })