diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f54e525..627f42f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ All notable changes to **basou** are recorded here. The project follows ### Added +- `@basou/sdk` v0.2 — the package gains a runtime, read-only programmatic API + for reading a workspace's provenance (it was types-only before). It is a + thin, ergonomic, semver-stable facade over `@basou/core`'s readers, so + third-party tooling can read `.basou/` without stitching low-level calls + together and without any risk of mutating provenance (no writers are + exposed). `openWorkspace(repoRoot, options?)` returns a `Workspace` handle — + `repoRoot` is any directory holding a `.basou/` (no git required); + `resolveWorkspaceRoot(cwd)` is a git-based convenience for finding it. The + handle exposes `manifest()`, `status()`, `listSessions()` / + `getSession(idOrPrefix)`, `readEvents(idOrPrefix)` / + `streamEvents(idOrPrefix)`, `listTasks()` / `getTask(idOrPrefix)`, + `listApprovals()` / `getApproval(id)`, `stats(options?)`, and + `renderHandoff()` / `renderDecisions()`. Session, task, and event lookups + accept a unique prefix (matching nothing yields `null`, matching more than + one throws `AmbiguousIdError`); `getApproval` takes an exact id. A missing or + invalid `.basou/` throws `WorkspaceNotFoundError`. A `now` clock is injectable + for deterministic reads. + Read types (`Session`, `Event`, `Task`, `WorkStatsResult`, etc.) are + re-exported so consumers import only from `@basou/sdk`. `BASOU_SDK_VERSION` + is now `0.2.0`. - `session.metrics.machine_active_time_ms` (additive, optional) — model compute time: the summed duration of a source's per-turn spans (Codex `task_complete.duration_ms`). A SUBSET of a session's active time, kept as a diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 236bc18a..52799be8 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -3,13 +3,13 @@ "version": "0.5.0", "type": "module", "license": "Apache-2.0", - "description": "Type-only SDK for Basou adapter authors. v0.1: types only, no runtime.", + "description": "Read-only SDK for Basou: a stable, ergonomic API to read a workspace's provenance (sessions, events, tasks, approvals, stats).", "author": "Takashi Matsuyama", "keywords": [ "ai", "agent", "sdk", - "adapter", + "provenance", "basou" ], "repository": { @@ -41,6 +41,9 @@ "engines": { "node": ">=20.10.0" }, + "dependencies": { + "@basou/core": "workspace:*" + }, "scripts": { "build": "tsup", "dev": "tsup --watch", diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts new file mode 100644 index 00000000..49d58b30 --- /dev/null +++ b/packages/sdk/src/errors.ts @@ -0,0 +1,41 @@ +/** + * Base class for every error the SDK throws on its own behalf. Errors that + * originate in `@basou/core` (e.g. a malformed `session.yaml`) propagate as-is; + * only the SDK's own preconditions are wrapped, so `instanceof BasouSdkError` + * identifies "the SDK rejected this call" rather than "the data was bad". + */ +export class BasouSdkError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = new.target.name; + } +} + +/** + * `openWorkspace` was pointed at a path that is not a usable Basou workspace: + * the `.basou/` directory is missing, is a symlink, or is otherwise not a + * directory. The offending repository root is on {@link root}. + */ +export class WorkspaceNotFoundError extends BasouSdkError { + readonly root: string; + constructor(root: string, options?: { cause?: unknown }) { + super( + `No Basou workspace at ${root}: expected a '.basou/' directory (run 'basou init' there first).`, + options, + ); + this.root = root; + } +} + +/** + * A session / task id prefix matched more than one record. The {@link input} + * is the prefix as given; the caller should retry with a longer one. (A prefix + * that matches nothing is NOT an error — the lookup returns `null` instead.) + */ +export class AmbiguousIdError extends BasouSdkError { + readonly input: string; + constructor(input: string, options?: { cause?: unknown }) { + super(`Ambiguous id '${input}': matched more than one record; use a longer prefix.`, options); + this.input = input; + } +} diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 730a7821..db6f88b8 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "vitest"; -import { BASOU_SDK_VERSION } from "./index.js"; +import { BASOU_SDK_VERSION, openWorkspace, resolveWorkspaceRoot } from "./index.js"; -describe("@basou/sdk skeleton", () => { - it("exposes BASOU_SDK_VERSION as 0.1.0", () => { - expect(BASOU_SDK_VERSION).toBe("0.1.0"); +describe("@basou/sdk surface", () => { + it("exposes BASOU_SDK_VERSION as 0.2.0 (first runtime read API)", () => { + expect(BASOU_SDK_VERSION).toBe("0.2.0"); + }); + + it("exports the workspace entry points", () => { + expect(typeof openWorkspace).toBe("function"); + expect(typeof resolveWorkspaceRoot).toBe("function"); }); }); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 454710d1..8ced3e71 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1 +1,75 @@ -export const BASOU_SDK_VERSION = "0.1.0"; +/** + * `@basou/sdk` — the stable, read-only programmatic API for reading a Basou + * workspace's provenance (`.basou/`). It is a thin, ergonomic facade over + * `@basou/core`'s readers: open a workspace once and query sessions, events, + * tasks, approvals, status, stats, and the rendered handoff / decisions. No + * writers are exposed — third-party tooling can read provenance without any + * risk of mutating it. + * + * @example + * ```ts + * import { openWorkspace, resolveWorkspaceRoot } from "@basou/sdk"; + * + * const root = await resolveWorkspaceRoot(process.cwd()); // or pass a known root + * const ws = await openWorkspace(root); + * for (const { session, suspect } of await ws.listSessions()) { + * console.log(session.session.id, session.session.status, suspect); + * } + * const stats = await ws.stats(); + * console.log(stats.totals.billableActiveTimeMs); + * ``` + */ + +/** + * SDK API version, tracking the Basou SDK surface (not the npm package + * version, which moves in lockstep with the monorepo). `0.2.0` is the first + * release with a runtime read API; `0.1.0` was types-only. + */ +export const BASOU_SDK_VERSION = "0.2.0"; + +// Read types re-exported from @basou/core so consumers can type the values the +// SDK returns without depending on @basou/core directly. These track the +// on-disk provenance schema. +export type { + ActiveTimeBasis, + Approval, + ApprovalStatus, + CommandExecutedEvent, + DayWorkStats, + DecisionRecordedEvent, + Event, + FileChangedEvent, + LoadedApproval, + Manifest, + MeasureAvailability, + NoteAddedEvent, + RiskLevel, + Session, + SessionEndedEvent, + SessionEntry, + SessionMetrics, + SessionSourceKind, + SessionStartedEvent, + SessionStatus, + SessionStatusChangedEvent, + SessionWorkStats, + SourceWorkStats, + StatusCount, + StatusSnapshot, + SuspectReason, + Task, + TaskDocument, + TaskStatus, + TokenTotals, + WorkStatsResult, + WorkStatsTotals, +} from "@basou/core"; +export { AmbiguousIdError, BasouSdkError, WorkspaceNotFoundError } from "./errors.js"; +export { + openWorkspace, + resolveWorkspaceRoot, + type StatsOptions, + type Workspace, + type WorkspaceDiagnostic, + type WorkspaceOptions, +} from "./workspace.js"; diff --git a/packages/sdk/src/workspace.test.ts b/packages/sdk/src/workspace.test.ts new file mode 100644 index 00000000..f0e09662 --- /dev/null +++ b/packages/sdk/src/workspace.test.ts @@ -0,0 +1,291 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { ensureBasouDirectory } from "@basou/core"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AmbiguousIdError, WorkspaceNotFoundError } from "./errors.js"; +import { openWorkspace } from "./workspace.js"; + +// Fixtures are written as JSON, which is valid YAML, so the core readers +// (yaml-parsed) accept them without pulling `yaml` into the SDK's own deps. +function toYaml(obj: unknown): string { + return JSON.stringify(obj, null, 2); +} + +const WS_ID = "ws_01HXABCDEF1234567890ABCWS1"; +const SES_DONE = "ses_01HXABCDEF1234567890ABCSEA"; +const SES_AMB1 = "ses_01HXABCDEF1234567890ABCAM1"; +const SES_AMB2 = "ses_01HXABCDEF1234567890ABCAM2"; +const TASK_ID = "task_01HXABCDEF1234567890ABCTK1"; +// APPR_DUP exists in BOTH pending/ and resolved/ (a stale pending file left +// after resolution); APPR_PENDING is genuinely pending. +const APPR_DUP = "appr_01HXABCDEF1234567890ABCAP1"; +const APPR_PENDING = "appr_01HXABCDEF1234567890ABCAP2"; + +let root: string | undefined; + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "basou-sdk-test-")); +}); + +afterEach(async () => { + if (root !== undefined) { + await rm(root, { recursive: true, force: true }); + root = undefined; + } +}); + +function getRoot(): string { + if (root === undefined) throw new Error("root not initialized"); + return root; +} + +function manifestYaml(): string { + return toYaml({ + schema_version: "0.1.0", + basou_version: "0.1.0", + workspace: { + id: WS_ID, + name: "sdk-test-workspace", + created_at: "2026-05-01T00:00:00+09:00", + updated_at: "2026-05-01T00:00:00+09:00", + }, + project: {}, + capabilities: { enabled: [] }, + approval: { default_risk_level: "low" }, + adapters: { "claude-code": { enabled: false } }, + git: { events_log: "ignore" }, + }); +} + +function sessionYaml(id: string, status: string): string { + return toYaml({ + schema_version: "0.1.0", + session: { + id, + workspace_id: WS_ID, + source: { kind: "codex-import", version: "0.1.0" }, + started_at: "2026-05-10T00:00:00.000Z", + ended_at: "2026-05-10T00:10:00.000Z", + status, + working_directory: "/tmp/fixture", + invocation: { command: "codex", args: [], exit_code: null }, + related_files: [], + events_log: "events.jsonl", + }, + }); +} + +function eventLine(obj: Record): string { + return `${JSON.stringify({ schema_version: "0.1.0", source: "codex-import", ...obj })}\n`; +} + +async function writeSession( + paths: { sessions: string }, + id: string, + status: string, + events: string, +): Promise { + const dir = join(paths.sessions, id); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "session.yaml"), sessionYaml(id, status)); + await writeFile(join(dir, "events.jsonl"), events); +} + +/** Scaffold a populated `.basou/` and return the repo root that holds it. */ +async function setupWorkspace(): Promise { + const repoRoot = getRoot(); + const paths = await ensureBasouDirectory(repoRoot); + await writeFile(paths.files.manifest, manifestYaml()); + + const doneEvents = + eventLine({ + id: "evt_01HXABCDEF1234567890ABCEV1", + session_id: SES_DONE, + type: "session_started", + occurred_at: "2026-05-10T00:00:00.000Z", + }) + + eventLine({ + id: "evt_01HXABCDEF1234567890ABCEV2", + session_id: SES_DONE, + type: "command_executed", + occurred_at: "2026-05-10T00:00:30.000Z", + command: "bash", + args: ["-c", "ls"], + cwd: "/tmp/fixture", + exit_code: 0, + duration_ms: 1500, + }) + + eventLine({ + id: "evt_01HXABCDEF1234567890ABCEV3", + session_id: SES_DONE, + type: "session_ended", + occurred_at: "2026-05-10T00:10:00.000Z", + }); + await writeSession(paths, SES_DONE, "completed", doneEvents); + // Two sessions sharing a prefix, to exercise ambiguous resolution. + await writeSession(paths, SES_AMB1, "completed", ""); + await writeSession(paths, SES_AMB2, "completed", ""); + + const taskYaml = toYaml({ + schema_version: "0.1.0", + task: { + id: TASK_ID, + title: "fixture task", + status: "planned", + created_at: "2026-05-10T00:00:00.000Z", + updated_at: "2026-05-10T00:00:00.000Z", + workspace_id: WS_ID, + created_in_session: SES_DONE, + linked_sessions: [SES_DONE], + }, + }); + await writeFile(join(paths.tasks, `${TASK_ID}.md`), `---\n${taskYaml}\n---\nbody\n`); + + const approval = (id: string, status: string): string => + toYaml({ + schema_version: "0.1.0", + id, + session_id: SES_DONE, + created_at: "2026-05-10T00:00:00.000Z", + status, + risk_level: "low", + action: { kind: "command" }, + reason: "fixture approval", + }); + await writeFile( + join(paths.approvals.resolved, `${APPR_DUP}.yaml`), + approval(APPR_DUP, "approved"), + ); + await writeFile(join(paths.approvals.pending, `${APPR_DUP}.yaml`), approval(APPR_DUP, "pending")); + await writeFile( + join(paths.approvals.pending, `${APPR_PENDING}.yaml`), + approval(APPR_PENDING, "pending"), + ); + + return repoRoot; +} + +describe("openWorkspace", () => { + it("throws WorkspaceNotFoundError when there is no .basou/", async () => { + await expect(openWorkspace(getRoot())).rejects.toBeInstanceOf(WorkspaceNotFoundError); + }); + + it("reads the manifest and a fresh status snapshot", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const manifest = await ws.manifest(); + expect(manifest.workspace.id).toBe(WS_ID); + const status = await ws.status(); + expect(status.directories_present.sessions).toBe(true); + }); + + it("lists sessions with their suspect classification", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const sessions = await ws.listSessions(); + expect(sessions.map((s) => s.sessionId)).toContain(SES_DONE); + const done = sessions.find((s) => s.sessionId === SES_DONE); + expect(done?.session.session.status).toBe("completed"); + expect(done?.suspect).toBe(false); + }); + + it("resolves a session by full id and by unique prefix", async () => { + const ws = await openWorkspace(await setupWorkspace()); + expect((await ws.getSession(SES_DONE))?.sessionId).toBe(SES_DONE); + // Unique prefix (the SEA-ending id) without the ses_ prefix. + expect((await ws.getSession("01HXABCDEF1234567890ABCSEA"))?.sessionId).toBe(SES_DONE); + }); + + it("returns null for an unknown session and throws on an ambiguous prefix", async () => { + const ws = await openWorkspace(await setupWorkspace()); + expect(await ws.getSession("ses_01HXABCDEF1234567890ABCZZZ")).toBeNull(); + await expect(ws.getSession("01HXABCDEF1234567890ABCAM")).rejects.toBeInstanceOf( + AmbiguousIdError, + ); + }); + + it("reads a session's events eagerly and as a stream", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const events = await ws.readEvents(SES_DONE); + expect(events.map((e) => e.type)).toEqual([ + "session_started", + "command_executed", + "session_ended", + ]); + const streamed = []; + for await (const e of ws.streamEvents(SES_DONE)) streamed.push(e.type); + expect(streamed).toEqual(["session_started", "command_executed", "session_ended"]); + }); + + it("returns an empty event list for an unknown session", async () => { + const ws = await openWorkspace(await setupWorkspace()); + expect(await ws.readEvents("ses_01HXABCDEF1234567890ABCZZZ")).toEqual([]); + }); + + it("lists and resolves tasks", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const tasks = await ws.listTasks(); + expect(tasks.map((t) => t.task.task.id)).toContain(TASK_ID); + expect((await ws.getTask("01HXABCDEF1234567890ABCTK1"))?.task.task.title).toBe("fixture task"); + expect(await ws.getTask("task_01HXABCDEF1234567890ABCZZZ")).toBeNull(); + }); + + it("computes stats across the workspace's sessions", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const stats = await ws.stats({ timeZone: "UTC" }); + expect(stats.totals.sessionCount).toBe(3); + expect(stats.totals.commandCount).toBe(1); + }); + + it("renders the handoff and decisions markdown", async () => { + const ws = await openWorkspace(await setupWorkspace()); + expect(typeof (await ws.renderHandoff())).toBe("string"); + expect(typeof (await ws.renderDecisions())).toBe("string"); + }); + + it("injects a clock for time-sensitive reads", async () => { + const fixed = new Date("2026-05-10T01:00:00.000Z"); + const ws = await openWorkspace(await setupWorkspace(), { now: () => fixed }); + const stats = await ws.stats(); + expect(stats.generatedAt).toBe(fixed.toISOString()); + }); + + it("lists approvals, reporting a resolved-and-stale-pending id once", async () => { + const ws = await openWorkspace(await setupWorkspace()); + const { pending, resolved } = await ws.listApprovals(); + // APPR_DUP is in both dirs: it must appear only under resolved. + expect(resolved.map((a) => a.approval.id)).toEqual([APPR_DUP]); + expect(resolved[0]?.location).toBe("resolved"); + expect(pending.map((a) => a.approval.id)).toEqual([APPR_PENDING]); + expect(pending.map((a) => a.approval.id)).not.toContain(APPR_DUP); + }); + + it("gets an approval by exact id (resolved-first), null when unknown", async () => { + const ws = await openWorkspace(await setupWorkspace()); + expect((await ws.getApproval(APPR_DUP))?.location).toBe("resolved"); + expect((await ws.getApproval(APPR_PENDING))?.location).toBe("pending"); + expect(await ws.getApproval("appr_01HXABCDEF1234567890ABCZZZ")).toBeNull(); + }); + + it("normalizes a relative root to an absolute path", async () => { + const repoRoot = await setupWorkspace(); + const cwd = process.cwd(); + try { + process.chdir(repoRoot); + // A relative "." must be resolved to the absolute cwd (= resolve(".")), + // not stored verbatim. Compared to process.cwd() rather than repoRoot to + // avoid tmpdir symlink differences on some platforms. + const ws = await openWorkspace("."); + expect(isAbsolute(ws.root)).toBe(true); + expect(ws.root).toBe(process.cwd()); + } finally { + process.chdir(cwd); + } + }); + + it("wraps the underlying cause on WorkspaceNotFoundError", async () => { + const error = await openWorkspace(getRoot()).catch((e: unknown) => e); + expect(error).toBeInstanceOf(WorkspaceNotFoundError); + expect((error as WorkspaceNotFoundError).root).toBe(getRoot()); + expect((error as WorkspaceNotFoundError).cause).toBeDefined(); + }); +}); diff --git a/packages/sdk/src/workspace.ts b/packages/sdk/src/workspace.ts new file mode 100644 index 00000000..4d5ba971 --- /dev/null +++ b/packages/sdk/src/workspace.ts @@ -0,0 +1,295 @@ +import { join, resolve } from "node:path"; +import { + assertBasouRootSafe, + basouPaths, + buildStatusSnapshot, + computeWorkStats, + type Event, + enumerateApprovals, + type LoadedApproval, + loadApproval, + loadSessionEntries, + loadTaskEntries, + type Manifest, + readAllEvents, + readManifest, + readTaskFileWithArchiveFallback, + renderDecisions, + renderHandoff, + replayEvents, + resolveRepositoryRoot, + resolveSessionId, + resolveTaskId, + type SessionEntry, + type StatusSnapshot, + type TaskDocument, + type WorkStatsResult, +} from "@basou/core"; +import { AmbiguousIdError, WorkspaceNotFoundError } from "./errors.js"; + +/** + * A degradation the SDK noticed while reading provenance: a malformed event + * line, or a session / task that could not be loaded. Best-effort reads skip + * these and keep going; pass `onDiagnostic` to {@link openWorkspace} to observe + * them. `message` is a human-readable summary (it folds in the core + * `ReplayWarning.kind` or skip-reason); structured fields are intentionally not + * part of this stable shape. + */ +export type WorkspaceDiagnostic = { + /** Human-readable summary of the malformed line / skipped record. */ + message: string; + /** Session or task id the diagnostic relates to, when known. */ + id?: string; +}; + +/** Options for {@link openWorkspace}; all optional. */ +export type WorkspaceOptions = { + /** + * Clock used for time-sensitive reads (session "suspect" classification, + * stats span-to-now, status / approval expiry). Injectable for deterministic + * callers and tests. Defaults to `() => new Date()`, evaluated per call. + */ + now?: () => Date; + /** + * Observe a malformed event line or a skipped session / task instead of it + * being silently dropped. Reads are still best-effort: a diagnostic does not + * fail the call. + */ + onDiagnostic?: (diagnostic: WorkspaceDiagnostic) => void; +}; + +/** Options for {@link Workspace.stats}. */ +export type StatsOptions = { + /** + * IANA timezone used to bucket the per-day breakdown (native logs are UTC). + * Defaults to the host's local zone. + */ + timeZone?: string; +}; + +/** + * A read-only handle on one Basou workspace (`/.basou/`). Every method + * reads provenance from disk; the SDK exposes no writers. Obtain one with + * {@link openWorkspace}. + * + * Session / task lookups (`getSession`, `getTask`, `readEvents`, + * `streamEvents`) accept a full id or a unique prefix: a prefix matching + * nothing yields `null` (or an empty stream), a prefix matching more than one + * record throws {@link AmbiguousIdError}. `getApproval` takes an exact id only. + */ +export interface Workspace { + /** Absolute repository root this workspace was opened at. */ + readonly root: string; + + /** Parsed `manifest.yaml`. */ + manifest(): Promise; + /** A freshly computed workspace status snapshot (directory presence + manifest). */ + status(): Promise; + + /** Every session, ULID-ascending, each with its `suspect` classification. */ + listSessions(): Promise; + /** One session by id / unique prefix, or `null` if no session matches. */ + getSession(idOrPrefix: string): Promise; + /** All events of a session, eagerly, ordered as written. Empty if no match. */ + readEvents(idOrPrefix: string): Promise; + /** All events of a session as a lazy stream (for large logs). */ + streamEvents(idOrPrefix: string): AsyncIterable; + + /** Every task (active + lazily-indexed), created-at ascending. */ + listTasks(): Promise; + /** One task by id / unique prefix (archived included), or `null`. */ + getTask(idOrPrefix: string): Promise; + + /** Pending + resolved approvals, fully loaded. */ + listApprovals(): Promise<{ pending: LoadedApproval[]; resolved: LoadedApproval[] }>; + /** One approval by exact id (resolved checked first), or `null`. */ + getApproval(id: string): Promise; + + /** Aggregated work / time / token stats across the workspace's sessions. */ + stats(options?: StatsOptions): Promise; + + /** The rendered `handoff.md` body (recomputed, without generated markers). */ + renderHandoff(): Promise; + /** The rendered `decisions.md` body (recomputed, without generated markers). */ + renderDecisions(): Promise; +} + +/** + * Resolve the Basou workspace root for a working directory by finding the + * enclosing git repository root (`.basou/` lives at the repo root). A + * convenience for the common "I'm somewhere in the repo" case; requires git + * and a repository. Pass the returned path to {@link openWorkspace}. When you + * already know the root (CI checkout, a copied `.basou/`), skip this and call + * {@link openWorkspace} directly — it needs no git. + */ +export function resolveWorkspaceRoot(cwd: string): Promise { + return resolveRepositoryRoot(cwd); +} + +/** + * Open a read-only handle on the Basou workspace rooted at `repoRoot` (the + * directory that contains `.basou/`). Validates that `.basou/` exists and is a + * real directory; throws {@link WorkspaceNotFoundError} otherwise. No git is + * required — point it at any directory holding a `.basou/`. + */ +export async function openWorkspace( + repoRoot: string, + options: WorkspaceOptions = {}, +): Promise { + // Normalize to an absolute path up front so `root` honors its documented + // absolute-path contract even when the caller passes a relative directory. + const root = resolve(repoRoot); + const paths = basouPaths(root); + try { + await assertBasouRootSafe(paths.root); + } catch (cause) { + throw new WorkspaceNotFoundError(root, { cause }); + } + const now = options.now ?? (() => new Date()); + const emit = options.onDiagnostic; + const onWarning = (warning: { kind: string; line?: number }, id?: string): void => + emit?.({ + message: `event ${warning.kind}${warning.line ? ` (line ${warning.line})` : ""}`, + ...(id !== undefined ? { id } : {}), + }); + const onSkip = (id: string, reason: string): void => + emit?.({ message: `skipped: ${reason}`, id }); + + /** Resolve a session prefix to a full id, or null when nothing matches. */ + const resolveSession = (input: string): Promise => + resolveOrNull(() => resolveSessionId(paths, input), input); + const resolveTask = (input: string): Promise => + resolveOrNull(() => resolveTaskId(paths, input, { includeArchived: true }), input); + + return { + root, + + manifest: () => readManifest(paths), + + status: async () => + buildStatusSnapshot({ manifest: await readManifest(paths), paths, now: now() }), + + listSessions: () => + loadSessionEntries(paths, { + now: now(), + onWarning: (w, sid) => onWarning(w, sid), + onSkip, + }), + + getSession: async (idOrPrefix) => { + const id = await resolveSession(idOrPrefix); + if (id === null) return null; + const entries = await loadSessionEntries(paths, { + now: now(), + onWarning: (w, sid) => onWarning(w, sid), + onSkip, + }); + return entries.find((e) => e.sessionId === id) ?? null; + }, + + readEvents: async (idOrPrefix) => { + const id = await resolveSession(idOrPrefix); + if (id === null) return []; + return readAllEvents(join(paths.sessions, id), { onWarning: (w) => onWarning(w, id) }); + }, + + streamEvents: (idOrPrefix): AsyncIterable => { + async function* iterate(): AsyncGenerator { + const id = await resolveSession(idOrPrefix); + if (id === null) return; + yield* replayEvents(join(paths.sessions, id), { onWarning: (w) => onWarning(w, id) }); + } + return iterate(); + }, + + listTasks: () => loadTaskEntries(paths, { onSkip }), + + getTask: async (idOrPrefix) => { + const id = await resolveTask(idOrPrefix); + if (id === null) return null; + const { doc } = await readTaskFileWithArchiveFallback(paths, id); + return doc; + }, + + listApprovals: async () => { + const ids = await enumerateApprovals(paths); + // `loadApproval` searches resolved/ before pending/, so an id present in + // BOTH (a stale pending file left after resolution) would otherwise load + // the resolved record into the pending list too. Drop those from pending + // so a resolved approval is reported once, under `resolved`. + const resolvedSet = new Set(ids.resolved); + const pendingIds = ids.pending.filter((id) => !resolvedSet.has(id)); + const load = async (id: string): Promise => loadApproval(paths, id); + const [pending, resolved] = await Promise.all([ + Promise.all(pendingIds.map(load)), + Promise.all(ids.resolved.map(load)), + ]); + return { + pending: pending.filter((a): a is LoadedApproval => a !== null), + resolved: resolved.filter((a): a is LoadedApproval => a !== null), + }; + }, + + getApproval: (id) => loadApproval(paths, id), + + stats: (statsOptions) => + computeWorkStats({ + paths, + now: now(), + ...(statsOptions?.timeZone !== undefined ? { timeZone: statsOptions.timeZone } : {}), + onWarning: (w, sid) => onWarning(w, sid), + onSessionSkip: onSkip, + }), + + renderHandoff: async () => { + const result = await renderHandoff({ + paths, + nowIso: now().toISOString(), + onWarning: (w, sid) => onWarning(w, sid), + onSessionSkip: onSkip, + onTaskSkip: onSkip, + }); + return result.body; + }, + + renderDecisions: async () => { + const result = await renderDecisions({ + paths, + nowIso: now().toISOString(), + onWarning: (w, sid) => onWarning(w, sid), + onSessionSkip: onSkip, + }); + return result.body; + }, + }; +} + +/** + * Run a core id-resolver and normalize its outcome: a successful resolution + * returns the id; the "not found" / "empty input" contract errors map to + * `null` (no such record); the "ambiguous" contract error maps to + * {@link AmbiguousIdError}. Any other error propagates unchanged. + */ +async function resolveOrNull( + resolver: () => Promise, + input: string, +): Promise { + try { + return await resolver(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // Match the core resolver's exact contract strings (id-resolver.ts), not a + // loose substring, so an unrelated error that merely contains "not found" + // is never silently swallowed to null. + if (/^Ambiguous (session|task) id /.test(message)) { + throw new AmbiguousIdError(input, { cause: error }); + } + if ( + /^(Session|Task) not found: /.test(message) || + /^(Session|Task) id is empty$/.test(message) + ) { + return null; + } + throw error; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5c531fd..74e62a60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,7 +54,11 @@ importers: specifier: ^4.4.3 version: 4.4.3 - packages/sdk: {} + packages/sdk: + dependencies: + '@basou/core': + specifier: workspace:* + version: link:../core packages: