@namzu/sdk 13.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts +2 -0
  3. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.d.ts.map +1 -0
  4. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js +159 -0
  5. package/dist/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.js.map +1 -0
  6. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  7. package/dist/manager/agent/lifecycle.js +33 -0
  8. package/dist/manager/agent/lifecycle.js.map +1 -1
  9. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts +2 -0
  10. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.d.ts.map +1 -0
  11. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js +149 -0
  12. package/dist/store/session/__tests__/a-workspace-can-be-configured.test.js.map +1 -0
  13. package/dist/store/session/disk.d.ts +3 -1
  14. package/dist/store/session/disk.d.ts.map +1 -1
  15. package/dist/store/session/disk.js +56 -2
  16. package/dist/store/session/disk.js.map +1 -1
  17. package/dist/store/session/memory.d.ts +3 -1
  18. package/dist/store/session/memory.d.ts.map +1 -1
  19. package/dist/store/session/memory.js +39 -2
  20. package/dist/store/session/memory.js.map +1 -1
  21. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  22. package/dist/tools/coordinator/agent.js +7 -0
  23. package/dist/tools/coordinator/agent.js.map +1 -1
  24. package/dist/tools/coordinator/index.d.ts.map +1 -1
  25. package/dist/tools/coordinator/index.js +6 -0
  26. package/dist/tools/coordinator/index.js.map +1 -1
  27. package/dist/types/agent/base.d.ts +22 -0
  28. package/dist/types/agent/base.d.ts.map +1 -1
  29. package/dist/types/session/store.d.ts +52 -0
  30. package/dist/types/session/store.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/src/manager/agent/__tests__/an-env-reaches-the-child-it-was-set-for.test.ts +196 -0
  33. package/src/manager/agent/lifecycle.ts +36 -0
  34. package/src/store/session/__tests__/a-workspace-can-be-configured.test.ts +210 -0
  35. package/src/store/session/disk.ts +63 -2
  36. package/src/store/session/memory.ts +46 -2
  37. package/src/tools/coordinator/agent.ts +7 -0
  38. package/src/tools/coordinator/index.ts +6 -0
  39. package/src/types/agent/base.ts +23 -0
  40. package/src/types/session/store.ts +60 -0
@@ -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
+ })
@@ -35,6 +35,7 @@ import type {
35
35
  CreateProjectParams,
36
36
  CreateSessionParams,
37
37
  CreateSubSessionParams,
38
+ ProjectConfigInput,
38
39
  SessionStore,
39
40
  SessionView,
40
41
  } from '../../types/session/store.js'
@@ -193,8 +194,8 @@ export class DiskSessionStore implements SessionStore {
193
194
  tenantId,
194
195
  name: params.name,
195
196
  config: {
196
- maxDelegationDepth: 4,
197
- maxDelegationWidth: 8,
197
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
198
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
198
199
  maxInterventionDepth: 10,
199
200
  },
200
201
  createdAt: now,
@@ -215,6 +216,66 @@ export class DiskSessionStore implements SessionStore {
215
216
  return deserializeProject(raw)
216
217
  }
217
218
 
219
+ async updateProject(
220
+ projectId: ProjectId,
221
+ config: ProjectConfigInput,
222
+ tenantId: TenantId,
223
+ ): Promise<Project | null> {
224
+ const existing = await this.getProject(projectId, tenantId)
225
+ if (!existing) return null
226
+ // Per field, like the in-memory store: an omitted limit is left alone
227
+ // rather than reset.
228
+ const project: Project = {
229
+ ...existing,
230
+ config: {
231
+ ...existing.config,
232
+ ...(config.maxDelegationDepth !== undefined
233
+ ? { maxDelegationDepth: config.maxDelegationDepth }
234
+ : {}),
235
+ ...(config.maxDelegationWidth !== undefined
236
+ ? { maxDelegationWidth: config.maxDelegationWidth }
237
+ : {}),
238
+ },
239
+ updatedAt: new Date(),
240
+ }
241
+ await atomicWriteJson(
242
+ join(this.projectDir(projectId), 'project.json'),
243
+ serializeProject(project),
244
+ )
245
+ return project
246
+ }
247
+
248
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
249
+ // Read from the directory rather than the lazily-built index: the index
250
+ // only knows about projects this instance has already touched, so a
251
+ // listing built from it would omit everything written by a previous
252
+ // process — which for a store whose whole point is durability is the
253
+ // wrong answer.
254
+ const projectsRoot = join(this.rootDir, 'projects')
255
+ let entries: string[]
256
+ try {
257
+ entries = await readdir(projectsRoot)
258
+ } catch {
259
+ return []
260
+ }
261
+
262
+ const found: Project[] = []
263
+ for (const entry of entries) {
264
+ const raw = await readJson<PersistedProject>(join(projectsRoot, entry, 'project.json'))
265
+ if (!raw) continue
266
+ // Another tenant's project is absent, not an error — a listing is a
267
+ // question about what you own, and refusing would leak that
268
+ // somebody else's project is there.
269
+ if (raw.tenantId !== tenantId) continue
270
+ found.push(deserializeProject(raw))
271
+ }
272
+ // Tie-broken by id — see the in-memory store. On a fast filesystem two
273
+ // projects share a millisecond routinely, and without this the order
274
+ // came from readdir.
275
+ found.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id))
276
+ return found
277
+ }
278
+
218
279
  // Session CRUD ------------------------------------------------------------
219
280
 
220
281
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -20,6 +20,7 @@ import type {
20
20
  CreateProjectParams,
21
21
  CreateSessionParams,
22
22
  CreateSubSessionParams,
23
+ ProjectConfigInput,
23
24
  SessionStore,
24
25
  SessionView,
25
26
  } from '../../types/session/store.js'
@@ -88,8 +89,8 @@ export class InMemorySessionStore implements SessionStore {
88
89
  tenantId,
89
90
  name: params.name,
90
91
  config: {
91
- maxDelegationDepth: 4,
92
- maxDelegationWidth: 8,
92
+ maxDelegationDepth: params.config?.maxDelegationDepth ?? 4,
93
+ maxDelegationWidth: params.config?.maxDelegationWidth ?? 8,
93
94
  maxInterventionDepth: 10,
94
95
  },
95
96
  createdAt: now,
@@ -106,6 +107,49 @@ export class InMemorySessionStore implements SessionStore {
106
107
  return record.project
107
108
  }
108
109
 
110
+ async updateProject(
111
+ projectId: ProjectId,
112
+ config: ProjectConfigInput,
113
+ tenantId: TenantId,
114
+ ): Promise<Project | null> {
115
+ const record = this.projects.get(projectId)
116
+ if (!record) return null
117
+ this.assertTenant(record.tenantId, tenantId, `project(${projectId})`)
118
+ // Per field: an omitted limit is left alone rather than reset, because a
119
+ // caller raising the width is saying nothing about the depth.
120
+ const project: Project = {
121
+ ...record.project,
122
+ config: {
123
+ ...record.project.config,
124
+ ...(config.maxDelegationDepth !== undefined
125
+ ? { maxDelegationDepth: config.maxDelegationDepth }
126
+ : {}),
127
+ ...(config.maxDelegationWidth !== undefined
128
+ ? { maxDelegationWidth: config.maxDelegationWidth }
129
+ : {}),
130
+ },
131
+ updatedAt: new Date(),
132
+ }
133
+ this.projects.set(projectId, { tenantId, project })
134
+ return project
135
+ }
136
+
137
+ async listProjects(tenantId: TenantId): Promise<readonly Project[]> {
138
+ const matches: Project[] = []
139
+ for (const record of this.projects.values()) {
140
+ if (record.tenantId !== tenantId) continue
141
+ matches.push(record.project)
142
+ }
143
+ // Tie-broken by id, because two projects created in the same
144
+ // millisecond otherwise fall back to insertion or directory order and
145
+ // "oldest first" stops being a total order. A caller paginating a
146
+ // listing that reorders under it sees items move between pages.
147
+ matches.sort(
148
+ (a, b) => a.createdAt.getTime() - b.createdAt.getTime() || a.id.localeCompare(b.id),
149
+ )
150
+ return matches
151
+ }
152
+
109
153
  // Session CRUD ------------------------------------------------------------
110
154
 
111
155
  async createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session> {
@@ -146,6 +146,13 @@ export function buildAgentTool(opts: AgentToolOptions): ToolDefinition {
146
146
  // surface, and the one it exports as the canonical shape —
147
147
  // did not.
148
148
  ...(context.parentSpan ? { parentSpan: context.parentSpan } : {}),
149
+ // The parent's environment, which is the whole point of setting
150
+ // one: a delegate that cannot see it runs against different
151
+ // services than the run that launched it, silently.
152
+ // `ToolContext.env` is the parent's own resolved map, per run.
153
+ ...(Object.keys(context.env ?? {}).length > 0
154
+ ? { configOverrides: { env: context.env } }
155
+ : {}),
149
156
  })
150
157
 
151
158
  onTaskLaunched?.(handle.taskId, {
@@ -484,6 +484,12 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
484
484
  // Hang the child run off THIS tool's span, so the delegation
485
485
  // shows up inside the turn that asked for it.
486
486
  ...(_context.parentSpan ? { parentSpan: _context.parentSpan } : {}),
487
+ // Same as the `Agent` tool: a delegate inherits the environment
488
+ // its parent was given, or it runs against different services
489
+ // than the run that asked for the work.
490
+ ...(Object.keys(_context.env ?? {}).length > 0
491
+ ? { configOverrides: { env: _context.env } }
492
+ : {}),
487
493
  })
488
494
 
489
495
  // Whose task this is. The inbox ignores completions for anything it
@@ -22,6 +22,29 @@ export interface BaseAgentConfig {
22
22
  maxResponseTokens?: number
23
23
  costLimitUsd?: number
24
24
  permissionMode?: PermissionMode
25
+
26
+ /**
27
+ * Extra environment variables for this agent's tools and sandboxed
28
+ * commands, merged over whatever ambient environment the execution path
29
+ * supplies. Inherited by every delegated descendant.
30
+ *
31
+ * **Configuration, not credentials** — and that is a property of the
32
+ * CHANNEL rather than a judgement about any particular value. This map is
33
+ * copied into every child, is readable by any tool that can run a command,
34
+ * and enters a model's context and the run transcript the moment something
35
+ * echoes it. Nothing here is scoped, redacted, or revocable.
36
+ *
37
+ * A value that authenticates to a host belongs on the brokered credential
38
+ * path instead, where the process holds a placeholder and the real value is
39
+ * attached per-host on egress — so it is never in the environment, never in
40
+ * a transcript, and never inherited by a child that had no business with it.
41
+ *
42
+ * Inheritance was broken until it was not: a child built through a
43
+ * `configBuilder` never received this at all, because the builder is
44
+ * written by whoever registered the agent and cannot forward a field it was
45
+ * never told about. It is stamped after the builder returns now, for the
46
+ * same reason `parentSpan` and `resumeHandler` are.
47
+ */
25
48
  env?: Record<string, string>
26
49
 
27
50
  /**
@@ -65,9 +65,42 @@ export interface CreateSubSessionParams {
65
65
  * is out of scope for this phase (session-hierarchy.md §11 defers the project
66
66
  * store to a later phase).
67
67
  */
68
+ /**
69
+ * The part of a Project's configuration a caller may actually set.
70
+ *
71
+ * **Exactly the fields something reads.** `ProjectConfig` declares eight; five
72
+ * enforcement sites read two of them, and the other six have zero readers in
73
+ * production — `maxInterventionDepth` included, whose three apparent hits are
74
+ * all comments claiming a wiring that does not exist. Exposing those here
75
+ * would make a dead field *easier to set*, which is worse than leaving it
76
+ * unreachable: a host would configure a retention policy, get no error, and
77
+ * believe retention was on.
78
+ *
79
+ * The rule is the repo's own: name the code that reads a declaration before
80
+ * shipping it. When a field gains a reader it gains a line here in the same
81
+ * change, and not before.
82
+ */
83
+ export interface ProjectConfigInput {
84
+ /** Read by the spawn path and both handoff paths. Default 4. */
85
+ maxDelegationDepth?: number
86
+ /** Read by the spawn path and broadcast handoff. Default 8. */
87
+ maxDelegationWidth?: number
88
+ }
89
+
68
90
  export interface CreateProjectParams {
69
91
  tenantId: TenantId
70
92
  name: string
93
+
94
+ /**
95
+ * Per-workspace limits. Omitted fields keep the defaults.
96
+ *
97
+ * Until this existed every project in existence ran at depth 4 / width 8,
98
+ * because the config was hardcoded identically in both stores and there was
99
+ * no way to write one afterwards. A tenant with several workspaces could
100
+ * not give them different limits, which is most of what having several
101
+ * workspaces is for.
102
+ */
103
+ config?: ProjectConfigInput
71
104
  }
72
105
 
73
106
  /**
@@ -99,6 +132,33 @@ export interface SessionStore {
99
132
 
100
133
  getProject(projectId: ProjectId, tenantId: TenantId): Promise<Project | null>
101
134
 
135
+ /**
136
+ * Change a Project's limits after it exists. OPTIONAL.
137
+ *
138
+ * Optional because widening a store interface is invisible to callers and
139
+ * fatal to implementors: a host with its own `SessionStore` should not stop
140
+ * compiling because the SDK grew a method. Callers check for it; the two
141
+ * stores here implement it.
142
+ *
143
+ * Only the fields in {@link ProjectConfigInput} can move, and an omitted
144
+ * field is left alone rather than reset — a caller raising the width is not
145
+ * saying anything about the depth. Returns the updated Project, or `null`
146
+ * if it does not exist.
147
+ */
148
+ updateProject?(
149
+ projectId: ProjectId,
150
+ config: ProjectConfigInput,
151
+ tenantId: TenantId,
152
+ ): Promise<Project | null>
153
+
154
+ /**
155
+ * Every Project this tenant owns, oldest first. OPTIONAL, same reasoning.
156
+ *
157
+ * The tenant is the isolation boundary, so this is scoped to it and to
158
+ * nothing else — there is no level above Project to filter by.
159
+ */
160
+ listProjects?(tenantId: TenantId): Promise<readonly Project[]>
161
+
102
162
  // Session CRUD ------------------------------------------------------------
103
163
 
104
164
  createSession(params: CreateSessionParams, tenantId: TenantId): Promise<Session>