@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +140 -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/lifecycle.d.ts.map +1 -1
  30. package/dist/manager/agent/lifecycle.js +6 -4
  31. package/dist/manager/agent/lifecycle.js.map +1 -1
  32. package/dist/manager/index.d.ts +2 -0
  33. package/dist/manager/index.d.ts.map +1 -1
  34. package/dist/manager/index.js +1 -0
  35. package/dist/manager/index.js.map +1 -1
  36. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts +2 -0
  37. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.d.ts.map +1 -0
  38. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js +208 -0
  39. package/dist/manager/project/__tests__/a-closed-workspace-takes-no-work.test.js.map +1 -0
  40. package/dist/manager/project/lifecycle.d.ts +66 -0
  41. package/dist/manager/project/lifecycle.d.ts.map +1 -0
  42. package/dist/manager/project/lifecycle.js +125 -0
  43. package/dist/manager/project/lifecycle.js.map +1 -0
  44. package/dist/public-runtime.d.ts +2 -2
  45. package/dist/public-runtime.d.ts.map +1 -1
  46. package/dist/public-runtime.js +12 -1
  47. package/dist/public-runtime.js.map +1 -1
  48. package/dist/session/errors.d.ts +71 -1
  49. package/dist/session/errors.d.ts.map +1 -1
  50. package/dist/session/errors.js +50 -0
  51. package/dist/session/errors.js.map +1 -1
  52. package/dist/session/handoff/broadcast.d.ts.map +1 -1
  53. package/dist/session/handoff/broadcast.js +4 -5
  54. package/dist/session/handoff/broadcast.js.map +1 -1
  55. package/dist/session/handoff/single.d.ts.map +1 -1
  56. package/dist/session/handoff/single.js +5 -4
  57. package/dist/session/handoff/single.js.map +1 -1
  58. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts +2 -0
  59. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.d.ts.map +1 -0
  60. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js +75 -0
  61. package/dist/session/migration/__tests__/a-migrated-project-id-is-an-id.test.js.map +1 -0
  62. package/dist/session/migration/filesystem.d.ts.map +1 -1
  63. package/dist/session/migration/filesystem.js +24 -0
  64. package/dist/session/migration/filesystem.js.map +1 -1
  65. package/dist/store/session/disk.d.ts +3 -1
  66. package/dist/store/session/disk.d.ts.map +1 -1
  67. package/dist/store/session/disk.js +64 -1
  68. package/dist/store/session/disk.js.map +1 -1
  69. package/dist/store/session/memory.d.ts +3 -1
  70. package/dist/store/session/memory.d.ts.map +1 -1
  71. package/dist/store/session/memory.js +39 -1
  72. package/dist/store/session/memory.js.map +1 -1
  73. package/dist/types/project/entity.d.ts +17 -0
  74. package/dist/types/project/entity.d.ts.map +1 -1
  75. package/dist/types/session/store.d.ts +28 -1
  76. package/dist/types/session/store.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/agents/runAgent.ts +9 -0
  79. package/src/connector/mcp/__tests__/a-transport-forgets-its-listeners.test.ts +127 -0
  80. package/src/connector/mcp/http-sse.ts +17 -0
  81. package/src/connector/mcp/stdio.ts +34 -1
  82. package/src/connector/mcp/streamable-http.ts +15 -1
  83. package/src/contracts/__tests__/an-id-the-sdk-mints-passes-its-own-schema.test.ts +84 -0
  84. package/src/contracts/schemas.ts +18 -1
  85. package/src/manager/agent/lifecycle.ts +6 -6
  86. package/src/manager/index.ts +3 -0
  87. package/src/manager/project/__tests__/a-closed-workspace-takes-no-work.test.ts +268 -0
  88. package/src/manager/project/lifecycle.ts +160 -0
  89. package/src/public-runtime.ts +14 -0
  90. package/src/session/errors.ts +85 -1
  91. package/src/session/handoff/broadcast.ts +4 -5
  92. package/src/session/handoff/single.ts +5 -4
  93. package/src/session/migration/__tests__/a-migrated-project-id-is-an-id.test.ts +92 -0
  94. package/src/session/migration/filesystem.ts +28 -0
  95. package/src/store/session/disk.ts +79 -2
  96. package/src/store/session/memory.ts +49 -2
  97. package/src/types/project/entity.ts +18 -0
  98. package/src/types/session/store.ts +35 -1
@@ -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')
@@ -23,11 +23,11 @@
23
23
 
24
24
  import { appendFile, mkdir, readFile, readdir, rm } from 'node:fs/promises'
25
25
  import { join } from 'node:path'
26
- import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
26
+ import { StaleProjectError, StaleSessionError, TenantIsolationError } from '../../session/errors.js'
27
27
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
28
28
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
29
29
  import type { Message } from '../../types/message/index.js'
30
- import type { Project } from '../../types/project/entity.js'
30
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
31
31
  import type { Session } from '../../types/session/entity.js'
32
32
  import type { ProjectId, SubSessionId, SummaryId, ThreadId } from '../../types/session/ids.js'
33
33
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -80,6 +80,10 @@ interface PersistedProject {
80
80
  tenantId: TenantId
81
81
  name: string
82
82
  config: Project['config']
83
+ /** Absent in files written before the workspace gained a status. */
84
+ status?: ProjectStatus
85
+ /** Absent in files written before the workspace gained a CAS counter. */
86
+ ownerVersion?: number
83
87
  createdAt: string
84
88
  updatedAt: string
85
89
  }
@@ -198,6 +202,8 @@ export class DiskSessionStore implements SessionStore {
198
202
  maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
199
203
  maxInterventionDepth: 10,
200
204
  },
205
+ status: 'open',
206
+ ownerVersion: 0,
201
207
  createdAt: now,
202
208
  updatedAt: now,
203
209
  }
@@ -245,6 +251,35 @@ export class DiskSessionStore implements SessionStore {
245
251
  return project
246
252
  }
247
253
 
254
+ async setProjectStatus(
255
+ projectId: ProjectId,
256
+ status: ProjectStatus,
257
+ tenantId: TenantId,
258
+ expectedOwnerVersion: number,
259
+ ): Promise<Project | null> {
260
+ const existing = await this.getProject(projectId, tenantId)
261
+ if (!existing) return null
262
+ // Against the version on disk, not the caller's copy of it.
263
+ if (existing.ownerVersion !== expectedOwnerVersion) {
264
+ throw new StaleProjectError({
265
+ projectId,
266
+ expectedOwnerVersion,
267
+ actualOwnerVersion: existing.ownerVersion,
268
+ })
269
+ }
270
+ const project: Project = {
271
+ ...existing,
272
+ status,
273
+ ownerVersion: existing.ownerVersion + 1,
274
+ updatedAt: new Date(),
275
+ }
276
+ await atomicWriteJson(
277
+ join(this.projectDir(projectId), 'project.json'),
278
+ serializeProject(project),
279
+ )
280
+ return project
281
+ }
282
+
248
283
  async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
249
284
  // Read from the directory rather than the lazily-built index: the index
250
285
  // only knows about projects this instance has already touched, so a
@@ -369,6 +404,40 @@ export class DiskSessionStore implements SessionStore {
369
404
  return results
370
405
  }
371
406
 
407
+ async listSessionsByProject(
408
+ projectId: ProjectId,
409
+ tenantId: TenantId,
410
+ ): Promise<readonly Session[]> {
411
+ // One directory, not the whole root: sessions live under their project,
412
+ // so this is the cheap direction. `listSessions` has to scan every
413
+ // project precisely because `threadId` is denormalised onto the record
414
+ // rather than expressed in the layout.
415
+ const sessionsRoot = join(this.projectDir(projectId), 'sessions')
416
+ let sessionDirs: string[]
417
+ try {
418
+ sessionDirs = await readdir(sessionsRoot)
419
+ } catch (err) {
420
+ const code = (err as NodeJS.ErrnoException).code
421
+ if (code === 'ENOENT') return []
422
+ throw err
423
+ }
424
+
425
+ const results: Session[] = []
426
+ for (const rawSessionId of sessionDirs) {
427
+ if (!rawSessionId.startsWith('ses_')) continue
428
+ const path = join(sessionsRoot, rawSessionId)
429
+ const raw = await readJson<PersistedSession>(join(path, 'session.json'))
430
+ if (!raw) continue
431
+ if (raw.tenantId !== tenantId) continue
432
+ results.push(deserializeSession(raw))
433
+ this.sessionIndex.set(raw.id, { sessionId: raw.id, projectId, path })
434
+ }
435
+ results.sort(
436
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
437
+ )
438
+ return results
439
+ }
440
+
372
441
  async updateSession(
373
442
  session: Session,
374
443
  tenantId: TenantId,
@@ -910,6 +979,8 @@ function serializeProject(p: Project): PersistedProject {
910
979
  tenantId: p.tenantId,
911
980
  name: p.name,
912
981
  config: p.config,
982
+ status: p.status,
983
+ ownerVersion: p.ownerVersion,
913
984
  createdAt: p.createdAt.toISOString(),
914
985
  updatedAt: p.updatedAt.toISOString(),
915
986
  }
@@ -921,6 +992,12 @@ function deserializeProject(p: PersistedProject): Project {
921
992
  tenantId: p.tenantId,
922
993
  name: p.name,
923
994
  config: p.config,
995
+ // A project.json written before these fields existed reads as an open
996
+ // workspace at version 0, which is what it was. Leaving `ownerVersion`
997
+ // undefined would be worse than a wrong default: every compare-and-set
998
+ // against it would fail, so an existing store could never be closed.
999
+ status: p.status ?? 'open',
1000
+ ownerVersion: p.ownerVersion ?? 0,
924
1001
  createdAt: new Date(p.createdAt),
925
1002
  updatedAt: new Date(p.updatedAt),
926
1003
  }
@@ -8,11 +8,11 @@
8
8
  * (Convention #5 deny-by-default, session-hierarchy.md §12.2).
9
9
  */
10
10
 
11
- import { StaleSessionError, TenantIsolationError } from '../../session/errors.js'
11
+ import { StaleProjectError, StaleSessionError, TenantIsolationError } from '../../session/errors.js'
12
12
  import { SessionAlreadySummarizedError } from '../../session/summary/errors.js'
13
13
  import type { MessageId, SessionId, TenantId } from '../../types/ids/index.js'
14
14
  import type { Message } from '../../types/message/index.js'
15
- import type { Project } from '../../types/project/entity.js'
15
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
16
16
  import type { Session } from '../../types/session/entity.js'
17
17
  import type { ProjectId, SubSessionId, ThreadId } from '../../types/session/ids.js'
18
18
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -93,6 +93,8 @@ export class InMemorySessionStore implements SessionStore {
93
93
  maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
94
94
  maxInterventionDepth: 10,
95
95
  },
96
+ status: 'open',
97
+ ownerVersion: 0,
96
98
  createdAt: now,
97
99
  updatedAt: now,
98
100
  }
@@ -134,6 +136,35 @@ export class InMemorySessionStore implements SessionStore {
134
136
  return project
135
137
  }
136
138
 
139
+ async setProjectStatus(
140
+ projectId: ProjectId,
141
+ status: ProjectStatus,
142
+ tenantId: TenantId,
143
+ expectedOwnerVersion: number,
144
+ ): Promise<Project | null> {
145
+ const record = this.projects.get(projectId)
146
+ if (!record) return null
147
+ this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
148
+ // Against the STORED version, not the caller's copy of it — comparing a
149
+ // value against itself is the shape the session CAS was written wrong in
150
+ // the first time.
151
+ if (record.project.ownerVersion !== expectedOwnerVersion) {
152
+ throw new StaleProjectError({
153
+ projectId,
154
+ expectedOwnerVersion,
155
+ actualOwnerVersion: record.project.ownerVersion,
156
+ })
157
+ }
158
+ const project: Project = {
159
+ ...record.project,
160
+ status,
161
+ ownerVersion: record.project.ownerVersion + 1,
162
+ updatedAt: new Date(),
163
+ }
164
+ this.projects.set(projectId, { tenantId, project })
165
+ return project
166
+ }
167
+
137
168
  async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
138
169
  const matches: Project[] = []
139
170
  for (const record of this.projects.values()) {
@@ -195,6 +226,22 @@ export class InMemorySessionStore implements SessionStore {
195
226
  return matches
196
227
  }
197
228
 
229
+ async listSessionsByProject(
230
+ projectId: ProjectId,
231
+ tenantId: TenantId,
232
+ ): Promise<readonly Session[]> {
233
+ const matches: Session[] = []
234
+ for (const record of this.sessions.values()) {
235
+ if (record.tenantId !== tenantId) continue
236
+ if (record.session.projectId !== projectId) continue
237
+ matches.push(record.session)
238
+ }
239
+ matches.sort(
240
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
241
+ )
242
+ return matches
243
+ }
244
+
198
245
  async updateSession(
199
246
  session: Session,
200
247
  tenantId: TenantId,
@@ -27,15 +27,33 @@ export interface ProjectConfig {
27
27
  retentionPolicy?: RetentionPolicy
28
28
  }
29
29
 
30
+ /**
31
+ * Whether the workspace accepts new work.
32
+ *
33
+ * Owner-managed, like the status it replaces: a project does not derive
34
+ * `archived` from having no live sessions, because "empty right now" and
35
+ * "closed" are different facts and only one of them is a decision.
36
+ */
37
+ export type ProjectStatus = 'open' | 'archived'
38
+
30
39
  /**
31
40
  * Long-lived goal scope that owns shared memory, vaults, knowledge bases,
32
41
  * and deliverables across sessions. See session-hierarchy.md §4.2.
42
+ *
43
+ * `status` is the gate the ingress paths read: an archived project accepts no
44
+ * new session and no handoff. It lives here rather than on Thread because the
45
+ * project is the thing a tenant actually closes — a workspace with its own
46
+ * limits, its own environment, and its own memory — and closing it has to mean
47
+ * something to the code, not only to a listing.
33
48
  */
34
49
  export interface Project {
35
50
  id: ProjectId
36
51
  tenantId: TenantId
37
52
  name: string
38
53
  config: ProjectConfig
54
+ status: ProjectStatus
55
+ /** CAS counter for status transitions. Mirrors `Session.ownerVersion`. */
56
+ ownerVersion: number
39
57
  createdAt: Date
40
58
  updatedAt: Date
41
59
  }
@@ -6,7 +6,7 @@
6
6
  * API — extensions land alongside their consumers.
7
7
  */
8
8
 
9
- import type { Project } from '../../types/project/entity.js'
9
+ import type { Project, ProjectStatus } from '../../types/project/entity.js'
10
10
  import type { ActorRef } from '../../types/session/actor.js'
11
11
  import type { Session } from '../../types/session/entity.js'
12
12
  import type { SessionMessage } from '../../types/session/messages.js'
@@ -159,6 +159,29 @@ export interface SessionStore {
159
159
  */
160
160
  listProjects?(tenantId: TenantId): Promise<readonly Project[]>
161
161
 
162
+ /**
163
+ * Open or close a workspace. OPTIONAL, same reasoning as the two above.
164
+ *
165
+ * Compare-and-set on {@link Project.ownerVersion}: pass the version you
166
+ * read, and a concurrent writer makes this throw `StaleProjectError`
167
+ * instead of silently winning. On success the stored version is bumped.
168
+ *
169
+ * Both directions, because a workspace is long-lived and closing one by
170
+ * mistake should not be permanent — unlike the Thread status this replaces,
171
+ * which only ever went one way. Returns `null` if the project does not
172
+ * exist; writing to another tenant's project throws.
173
+ *
174
+ * This is the store-level write. The precondition that no live session is
175
+ * attached belongs to {@link import('../../manager/project/lifecycle.js').ProjectManager},
176
+ * because the store deliberately holds no view of what is running.
177
+ */
178
+ setProjectStatus?(
179
+ projectId: ProjectId,
180
+ status: ProjectStatus,
181
+ tenantId: TenantId,
182
+ expectedOwnerVersion: number,
183
+ ): Promise<Project | null>
184
+
162
185
  // Session CRUD ------------------------------------------------------------
163
186
 
164
187
  createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session>
@@ -213,6 +236,17 @@ export interface SessionStore {
213
236
  */
214
237
  listSessions(threadId: ThreadId, tenantId: TenantId): Promise<readonly Session[]>
215
238
 
239
+ /**
240
+ * Every Session attached to a workspace, oldest first. OPTIONAL.
241
+ *
242
+ * The project-scoped sibling of {@link SessionStore.listSessions}, and the
243
+ * one the archive precondition reads: closing a workspace has to know what
244
+ * is still running in it, and "what is running in this thread" was never
245
+ * the question — a project can hold sessions across many threads, and after
246
+ * the Thread level is removed it is the only grouping left.
247
+ */
248
+ listSessionsByProject?(projectId: ProjectId, tenantId: TenantId): Promise<readonly Session[]>
249
+
216
250
  /**
217
251
  * Hard-delete a session. Idempotent — absent sessions succeed as a no-op.
218
252
  * Rejects with `TenantIsolationError` on cross-tenant access.