/** * SessionStore — canonical persistence contract for the session hierarchy. * * Every accessor takes explicit {@link TenantId} (Convention #17). Cross-tenant * access rejects with `TenantIsolationError`. Convention #0: no speculative * API — extensions land alongside their consumers. */ import type { Project, ProjectStatus } from '../../types/project/entity.js'; import type { ActorRef } from '../../types/session/actor.js'; import type { Session } from '../../types/session/entity.js'; import type { CompletionMode, FailureMode, SubSession, SubSessionKind } from '../../types/session/sub-session.js'; import type { SessionSummaryRef } from '../../types/summary/ref.js'; import type { SessionId, TenantId } from '../ids/index.js'; import type { ProjectId, SubSessionId, SummaryId, TopicId } from '../session/ids.js'; /** * Params for {@link SessionStore.createSession}. The store owns id generation, * `ownerVersion` initialization, and timestamps. * * Both `topicId` and `projectId` are required. `projectId` must equal the * `projectId` of the topic identified by `topicId`; the store does NOT * perform that cross-store consistency check (it has no TopicStore handle * by design — see the store-boundary rationale in {@link import('../topic/store.js').TopicStore}). The * caller is the authority; typically spawn and handoff paths copy both from * a freshly-loaded `Topic` record or from their own context which already * tracks both. */ export interface CreateSessionParams { /** * The id the session is created under. A host that needs to know a * conversation's id before the conversation is written — to name a * log, to label a hook, to show it — mints one with `generateSessionId` * and passes it here; the store refuses an id it already holds. Absent, * the store mints one. */ id?: SessionId; topicId: TopicId; projectId: ProjectId; /** * Initial owner of the session. May be `null` for bootstrap scenarios where * the first turn attaches an actor; the store rejects mutations against * actor-less sessions from downstream consumers. */ currentActor: ActorRef | null; } /** * Params for {@link SessionStore.createSubSession}. `workspaceId` is optional * because Phase 3 does not yet wire workspace creation into spawn — Phase 6 * makes the two atomic. `summaryRef` is populated by the materializer in * Phase 5, never by callers of this API. */ export interface CreateSubSessionParams { parentSessionId: SessionId; childSessionId: SessionId; kind: SubSessionKind; spawnedBy: ActorRef; failureMode?: FailureMode; completionMode?: CompletionMode; } /** * Minimal Project surface needed by the store in Phase 3. The full Project * entity lives in `session/hierarchy/project.ts`; a dedicated `ProjectStore` * is out of scope for this phase (session-hierarchy.md §11 defers the project * store to a later phase). */ /** * The part of a Project's configuration a caller may actually set. * * **Exactly the fields something reads.** `ProjectConfig` declares eight; five * enforcement sites read two of them, and the other six have zero readers in * production — `maxInterventionDepth` included, whose three apparent hits are * all comments claiming a wiring that does not exist. Exposing those here * would make a dead field *easier to set*, which is worse than leaving it * unreachable: a host would configure a retention policy, get no error, and * believe retention was on. * * The rule is the repo's own: name the code that reads a declaration before * shipping it. When a field gains a reader it gains a line here in the same * change, and not before. */ export interface ProjectConfigInput { /** Read by the spawn path and both handoff paths. Default 4. */ maxDelegationDepth?: number; /** Read by the spawn path and broadcast handoff. Default 8. */ maxDelegationWidth?: number; } export interface CreateProjectParams { tenantId: TenantId; name: string; /** * Per-workspace limits. Omitted fields keep the defaults. * * Until this existed every project in existence ran at depth 4 / width 8, * because the config was hardcoded identically in both stores and there was * no way to write one afterwards. A tenant with several workspaces could * not give them different limits, which is most of what having several * workspaces is for. */ config?: ProjectConfigInput; /** * The directory this project's work happens in. Canonicalized through * `realpath` before storage, so a symlink or a trailing slash cannot * produce a second record for one directory. * * A second project for the same canonical directory is REFUSED, not * deduplicated into the first — a caller who wanted the existing one * should say so with {@link SessionStore.findProjectByRootPath}, and * silently returning it would hide that their `name` and `config` were * discarded. */ rootPath?: string; } /** * Return shape for {@link SessionStore.drill}. See session-hierarchy.md §14.3. * The fields are final — consumers may rely on exhaustiveness (Convention #6). * * `ancestry` is root-to-self. `children` lists direct sub-sessions only * (recursive drill-down is the consumer's responsibility). */ export interface SessionView { session: Session; children: readonly SubSession[]; ancestry: readonly SessionId[]; } /** * Canonical persistence contract. Every accessor takes explicit `tenantId`. * Cross-tenant reads/writes must reject with `TenantIsolationError` * (see `session/errors.ts`). * * Read accessors return `null` when the resource does not exist for the * supplied tenant — this is the deny-by-default surface (Convention #5): * callers never get a fallback and must branch on missing explicitly. */ export interface SessionStore { createProject(params: CreateProjectParams, tenantId: TenantId): Promise; getProject(projectId: ProjectId, tenantId: TenantId): Promise; /** * The project bound to a directory, or `null`. OPTIONAL. * * Optional for the reason `updateProjectConfig` below gives: this * interface is implemented by hosts, and a required method stops them * compiling for a capability they never asked for. * * The argument is canonicalized here too, so a caller may pass whatever * they have — a relative path, a symlink, a trailing slash — and get the * same answer the writer got. */ findProjectByRootPath?(rootPath: string, tenantId: TenantId): Promise; /** * Change a Project's limits after it exists. OPTIONAL. * * Optional because widening a store interface is invisible to callers and * fatal to implementors: a host with its own `SessionStore` should not stop * compiling because the SDK grew a method. Callers check for it; the two * stores here implement it. * * Only the fields in {@link ProjectConfigInput} can move, and an omitted * field is left alone rather than reset — a caller raising the width is not * saying anything about the depth. Returns the updated Project, or `null` * if it does not exist. */ updateProject?(projectId: ProjectId, config: ProjectConfigInput, tenantId: TenantId): Promise; /** * Every Project this tenant owns, oldest first. OPTIONAL, same reasoning. * * The tenant is the isolation boundary, so this is scoped to it and to * nothing else — there is no level above Project to filter by. */ listProjects?(tenantId: TenantId): Promise; /** * Open or close a workspace. OPTIONAL, same reasoning as the two above. * * Compare-and-set on {@link Project.ownerVersion}: pass the version you * read, and a concurrent writer makes this throw `StaleProjectError` * instead of silently winning. On success the stored version is bumped. * * Both directions, because a workspace is long-lived and closing one by * mistake should not be permanent — unlike the Thread status this replaces, * which only ever went one way. Returns `null` if the project does not * exist; writing to another tenant's project throws. * * This is the store-level write. The precondition that no live session is * attached belongs to {@link import('../../manager/project/lifecycle.js').ProjectManager}, * because the store deliberately holds no view of what is running. */ setProjectStatus?(projectId: ProjectId, status: ProjectStatus, tenantId: TenantId, expectedOwnerVersion: number): Promise; createSession(params: CreateSessionParams, tenantId: TenantId): Promise; getSession(sessionId: SessionId, tenantId: TenantId): Promise; /** * Write a Session back, optionally only if nobody else wrote it first. * * **`expectedOwnerVersion` is the single-writer lock this level is supposed * to own, and it did not exist.** `Session.ownerVersion` is documented as * the CAS counter for handoff, but nothing enforced it: both stores * overwrote unconditionally, and the handoff's own check compared a * snapshot it had read several awaits earlier against itself. Two * concurrent handoffs on one idle session both passed, both provisioned a * worktree, and one silently erased the other. * * Supply it and the store compares against the version it HAS STORED — * not against the payload, which is the caller's stale copy — and throws * {@link StaleSessionError} rather than writing. Omit it and the behaviour * is exactly what it always was, which is the compatibility promise: this * parameter is optional so that widening the interface stays invisible to * callers and harmless to hosts implementing their own store. A required * parameter would break every implementor for a guarantee they can opt * into. * * **In-process only, stated rather than implied.** `DiskSessionStore` * writes atomically, but its read-compare-write is not a critical section, * so two PROCESSES can still both pass the check. Closing that needs a * lease with an expiry — not a PID registry, because a Session is durable * and written from hosts where a PID is not a checkable fact. The same * honesty the spawn lock already carries. */ updateSession(session: Session, tenantId: TenantId, expectedOwnerVersion?: number): Promise; /** * List every Session that belongs to the given Topic for the caller's * tenant, ordered by `createdAt` ascending. Returns an empty array when * none exist. * * Renamed from `listSessions` alongside the `threadId` → `topicId` field * rename (NZ-TOPIC-03) — a query method named after the retired word * would have kept pointing at the concept this whole chain exists to * rename. Topic-scoped queries rely on `session.topicId` (set at * creation, never rewritten). Cross-tenant sessions that happen to share * the supplied `topicId` are silently skipped — the listing is * tenant-scoped, not an isolation violation (the caller did not request * a specific record). * * Exists to back TopicManager's archival + delete preconditions * ({@link import('../../manager/topic/lifecycle.js').TopicManager.archive} * rejects when any session is in a non-terminal state; `delete` rejects * while any session still references the topic). Keeping this primitive * on {@link SessionStore} preserves the store-ownership boundary — * TopicStore stays unaware of session layout (Convention #0). */ listSessionsByTopic(topicId: TopicId, tenantId: TenantId): Promise; /** * Every Session attached to a workspace, oldest first. OPTIONAL. * * The project-scoped sibling of {@link SessionStore.listSessionsByTopic}, * and the one the archive precondition reads: closing a workspace has to * know what is still running in it, and "what is running in this topic" * was never the question — a project can hold sessions across many * topics, and after the Topic level is removed it is the only grouping * left. */ listSessionsByProject?(projectId: ProjectId, tenantId: TenantId): Promise; /** * Hard-delete a session. Idempotent — absent sessions succeed as a no-op. * Rejects with `TenantIsolationError` on cross-tenant access. * * Closes the Phase 4 Known Delta (broadcast rollback previously had to * flip status to `'archived'` as a stopgap). Used by: * - Broadcast rollback (compensating cleanup — pattern doc §6.2) * - Archival tombstone consolidation when a caller prefers deletion * over the in-slot tombstone (uncommon — default is in-slot). * * Policy: rejects when the session still has sub-sessions attached — * callers must delete children first. This keeps the operation a single, * locally-reasoning write rather than an implicit recursive cascade * (Convention #5 deny-by-default). */ deleteSession(sessionId: SessionId, tenantId: TenantId): Promise; createSubSession(params: CreateSubSessionParams, tenantId: TenantId): Promise; getSubSession(subSessionId: SubSessionId, tenantId: TenantId): Promise; updateSubSession(subSession: SubSession, tenantId: TenantId): Promise; /** * Hard-delete a sub-session record. Idempotent — absent sub-sessions * succeed as a no-op. Rejects with `TenantIsolationError` on cross-tenant * access. Does not cascade to the owned child session; the caller owns * that (typical broadcast-rollback flow deletes the sub-session first, * then the child session). */ deleteSubSession(subSessionId: SubSessionId, tenantId: TenantId): Promise; /** * Direct children of the session (one level). Returns an empty array when * the session has no delegations. */ getChildren(sessionId: SessionId, tenantId: TenantId): Promise; /** * Session id chain from root to self, inclusive. Walks parent sub-session * links. Rejects on cycle via `session/errors.ts#AncestryCycleError` — * the write path enforces acyclicity, so a cycle here indicates store * corruption. */ getAncestry(sessionId: SessionId, tenantId: TenantId): Promise; /** * Single-round navigation primitive. Returns `null` when the session does * not exist for the tenant. See session-hierarchy.md §14.3. */ drill(sessionId: SessionId, tenantId: TenantId): Promise; /** * @internal Kernel-internal. Call through * `SessionSummaryMaterializer.materialize`, never directly. The * `materializedBy: 'kernel'` constraint on the argument type ensures * external callers cannot construct a valid input — the only mint site for * `SummaryId` is `generateSummaryId` inside the Materializer. * * Atomic write-then-status-flip (Convention #8): persists the summary and * transitions the owning Session's status to `'idle'` if it was in a * non-terminal state (`'active' | 'locked' | 'awaiting_merge'`). The two * writes commit as one logical unit; mid-crash recovery is replay via * `SessionSummaryMaterializer.recover()`. * * Rejects with {@link SessionAlreadySummarizedError} if a summary already * exists for the session (re-materialization forbidden; see * session-hierarchy.md §4.7 immutability invariant). */ recordSummary(summary: SessionSummaryRef & { materializedBy: 'kernel'; }, tenantId: TenantId): Promise; /** * Loads the persisted summary for a session. Returns `null` when none has * been materialized. Cross-tenant reads reject with `TenantIsolationError` * (Convention #17). */ getSummary(sessionId: SessionId, tenantId: TenantId): Promise; } /** * Re-export of {@link SummaryId} so downstream consumers importing from * `types/session/store.js` pick up the brand alongside the store contract. */ export type { SummaryId }; //# sourceMappingURL=store.d.ts.map