diff --git a/docs/STORAGE_REVAMP_PLAN.md b/docs/STORAGE_REVAMP_PLAN.md deleted file mode 100644 index 48090faeb..000000000 --- a/docs/STORAGE_REVAMP_PLAN.md +++ /dev/null @@ -1,217 +0,0 @@ -# Storage Revamp Plan - -Status: active rollout, phases 1 and 2 complete - -This document now tracks the storage revamp as it exists today, not as a speculative design exercise. -The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit -creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths. - -## Goals - -- make structured records durable in Drizzle + SQLite -- make projects and workspaces explicit first-class records -- stop deriving project/workspace identity from agent `cwd` -- keep agent snapshot persistence behind clear ownership -- move committed timeline history to storage-owned rows -- remove legacy JSON and in-memory authority once the DB path is proven - -## Out of scope - -- moving config, keypairs, push tokens, or server identity into the DB -- persisting raw provider deltas or transport-only chunk streams -- designing a hosted/remote database story beyond keeping the schema portable -- durable reasoning history unless product explicitly asks for it later - -## Current state - -The storage revamp is no longer hypothetical. - -Completed: - -- Drizzle + SQLite database bootstrap is in place -- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys -- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete -- `agent_snapshots.workspace_id` is `NOT NULL` -- legacy JSON import feeds the DB-backed structured records -- project/workspace records use explicit `directory` fields instead of path-as-identity -- session read paths now use persisted workspace/project rows instead of cwd/git derivation -- `workspace-reconciliation-service.ts` is deleted -- `workspace-registry-bootstrap.ts` is deleted -- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId` - -Still pending: - -- explicit `create_project` / `create_workspace` API cleanup -- final archive cascade behavior for descendants and live agents -- committed timeline storage cutover -- removal of remaining legacy JSON and in-memory committed-history authority - -## Converged decisions - -### Structured record authority - -Projects, workspaces, and agent snapshots are DB-backed structured records. -The server should not recreate project/workspace identity from: - -- git remotes -- worktree main-repo roots -- normalized cwd strings - -Temporary exception: - -- agent creation may still find-or-create a workspace by directory if the UI has not yet provided - `workspaceId` explicitly - -That fallback is transitional and should be deleted once the client always sends the workspace id. - -### Storage seams - -The useful seams remain concrete and domain-shaped: - -- `ProjectRegistry` -- `WorkspaceRegistry` -- `AgentSnapshotStore` -- `AgentTimelineStore` - -There is no reason to reintroduce a reconciliation service layer for project/workspace identity. - -### Timeline contract - -The long-term timeline contract remains: - -- committed rows are durable, canonical history -- provisional live updates are transient subscription state -- committed history is fetched by seq -- provider history replay is not the durability mechanism - -The structured-record cutover is complete before the timeline cutover so timeline rows can rely on -stable DB-backed agent and workspace identity. - -## Remaining phases - -### Phase 3: Explicit creation and archive cleanup - -Goal: -Remove the last transitional write paths that still infer state from directories. - -Required work: - -- add explicit `create_project` handling -- add explicit `create_workspace` handling -- make agent creation require `workspaceId` once the UI is ready -- finish archive semantics for workspaces/projects and any descendant agent state -- remove the temporary find-or-create-by-directory fallback from agent creation - -Exit gate: - -- project/workspace creation is explicit end to end -- no normal creation path infers identity from cwd or git metadata -- archive flows behave consistently for structured records and live runtime state - -### Phase 4: Timeline storage cutover - -Goal: -Make committed history durable and storage-owned. - -Required work: - -- make `AgentTimelineStore` authoritative for committed history -- write one committed row per finalized logical item -- support tail, before-seq, and after-seq queries from storage -- stop treating provider history hydration as the normal refresh/load path -- keep provisional live updates in memory only - -Exit gate: - -- committed history survives daemon restart -- reconnect uses committed catch-up plus future live events without gaps or duplicates -- unloaded agents can serve committed history from storage alone - -### Phase 5: Legacy cleanup - -Goal: -Remove compatibility paths after the DB-backed model is fully authoritative. - -Required work: - -- remove legacy JSON authority for structured records -- remove in-memory committed-history ownership -- remove provider-history rehydrate compatibility paths -- trim dead protocol and reducer logic from the pre-storage model -- update architecture docs to match the final model - -Exit gate: - -- there is one durable storage path for structured records -- there is one durable storage path for committed timeline history -- the runtime no longer depends on the removed JSON/in-memory model - -## Data model summary - -### Projects - -- integer primary key -- `directory` is unique -- `display_name` -- `kind`: `git | directory` -- optional `git_remote` -- timestamps and archive state - -### Workspaces - -- integer primary key -- belongs to a project by `project_id` -- `directory` is unique -- `display_name` -- `kind`: `checkout | worktree` -- timestamps and archive state - -### Agent snapshots - -- `agent_id` remains the primary key -- belongs to a workspace by integer `workspace_id` -- `workspace_id` is required -- timestamps, lifecycle state, persistence metadata, attention metadata, archive state - -### Timeline rows - -Target shape once Phase 4 lands: - -- `agent_id` -- committed `seq` -- committed timestamp -- canonical finalized item payload - -Not part of durable history: - -- raw streaming chunks -- provisional assistant text -- provisional reasoning text - -## Verification requirements - -Every remaining phase should keep the same bar: - -- `npm run typecheck` -- targeted tests for the touched storage/session/runtime paths -- migration/import coverage when storage authority changes -- reconnect and catch-up scenario coverage when timeline behavior changes - -At minimum, timeline cutover must explicitly prove: - -- `fetch-after-seq` -- `fetch-before-seq` -- restart durability -- no-gap/no-duplicate reconnect behavior - -## Main risks - -- timeline work reintroduces provider-history replay as hidden authority -- archive behavior diverges between stored records and live in-memory agents -- explicit creation work leaves the transitional cwd fallback in place too long -- cleanup stalls after compatibility paths stop being exercised - -## Rule of thumb - -If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity, -it is probably moving in the wrong direction. diff --git a/package-lock.json b/package-lock.json index 3348ae3bc..059b2308c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3072,13 +3072,6 @@ "react": ">=16.8.0" } }, - "node_modules/@drizzle-team/brocli": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", - "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -3536,442 +3529,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@esbuild-kit/core-utils": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", - "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.18.20", - "source-map-support": "^0.5.21" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, - "node_modules/@esbuild-kit/esm-loader": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", - "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", - "deprecated": "Merged into tsx: https://tsx.is", - "dev": true, - "license": "MIT", - "dependencies": { - "@esbuild-kit/core-utils": "^3.3.2", - "get-tsconfig": "^4.7.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -11707,16 +11264,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/better-sqlite3": { - "version": "7.6.13", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", - "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -14423,20 +13970,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -14458,64 +13991,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/blake3-wasm": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", @@ -16333,6 +15808,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, "license": "MIT", "dependencies": { "mimic-response": "^3.1.0" @@ -16348,6 +15824,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -16866,631 +16343,6 @@ "url": "https://dotenvx.com" } }, - "node_modules/drizzle-kit": { - "version": "0.31.10", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", - "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@drizzle-team/brocli": "^0.10.2", - "@esbuild-kit/esm-loader": "^2.5.5", - "esbuild": "^0.25.4", - "tsx": "^4.21.0" - }, - "bin": { - "drizzle-kit": "bin.cjs" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/drizzle-orm": { - "version": "0.45.2", - "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", - "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", - "license": "Apache-2.0", - "peerDependencies": { - "@aws-sdk/client-rds-data": ">=3", - "@cloudflare/workers-types": ">=4", - "@electric-sql/pglite": ">=0.2.0", - "@libsql/client": ">=0.10.0", - "@libsql/client-wasm": ">=0.10.0", - "@neondatabase/serverless": ">=0.10.0", - "@op-engineering/op-sqlite": ">=2", - "@opentelemetry/api": "^1.4.1", - "@planetscale/database": ">=1.13", - "@prisma/client": "*", - "@tidbcloud/serverless": "*", - "@types/better-sqlite3": "*", - "@types/pg": "*", - "@types/sql.js": "*", - "@upstash/redis": ">=1.34.7", - "@vercel/postgres": ">=0.8.0", - "@xata.io/client": "*", - "better-sqlite3": ">=7", - "bun-types": "*", - "expo-sqlite": ">=14.0.0", - "gel": ">=2", - "knex": "*", - "kysely": "*", - "mysql2": ">=2", - "pg": ">=8", - "postgres": ">=3", - "sql.js": ">=1", - "sqlite3": ">=5" - }, - "peerDependenciesMeta": { - "@aws-sdk/client-rds-data": { - "optional": true - }, - "@cloudflare/workers-types": { - "optional": true - }, - "@electric-sql/pglite": { - "optional": true - }, - "@libsql/client": { - "optional": true - }, - "@libsql/client-wasm": { - "optional": true - }, - "@neondatabase/serverless": { - "optional": true - }, - "@op-engineering/op-sqlite": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@planetscale/database": { - "optional": true - }, - "@prisma/client": { - "optional": true - }, - "@tidbcloud/serverless": { - "optional": true - }, - "@types/better-sqlite3": { - "optional": true - }, - "@types/pg": { - "optional": true - }, - "@types/sql.js": { - "optional": true - }, - "@upstash/redis": { - "optional": true - }, - "@vercel/postgres": { - "optional": true - }, - "@xata.io/client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "bun-types": { - "optional": true - }, - "expo-sqlite": { - "optional": true - }, - "gel": { - "optional": true - }, - "knex": { - "optional": true - }, - "kysely": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "pg": { - "optional": true - }, - "postgres": { - "optional": true - }, - "prisma": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - } - } - }, "node_modules/dtrace-provider": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", @@ -19644,15 +18496,6 @@ "dev": true, "license": "MIT" }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/expect": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", @@ -22273,12 +21116,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "license": "MIT" - }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -22722,12 +21559,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "license": "MIT" - }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -22999,12 +21830,6 @@ "node": ">=6" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -27898,12 +26723,6 @@ "node": ">=10" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "license": "MIT" - }, "node_modules/mnemonic-id": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/mnemonic-id/-/mnemonic-id-3.2.7.tgz", @@ -28093,12 +26912,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -29715,45 +28528,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/node-abi": { - "version": "3.89.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", - "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -32429,51 +31203,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-plist": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", @@ -32967,6 +31696,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.1.0" @@ -32976,6 +31706,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, "license": "MIT" }, "node_modules/string-length": { @@ -33449,54 +32180,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, - "node_modules/tar-fs/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tar-fs/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", @@ -34272,6 +32955,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" @@ -34943,6 +33627,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -36374,7 +35059,6 @@ "ws": "^8.14.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.3", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "41.0.3", @@ -36661,9 +35345,7 @@ "@xterm/headless": "^6.0.0", "ai": "5.0.78", "ajv": "^8.17.1", - "better-sqlite3": "^12.8.0", "dotenv": "^17.2.3", - "drizzle-orm": "^0.45.1", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-deep-equal": "^3.1.3", @@ -36689,7 +35371,6 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", - "@types/better-sqlite3": "^7.6.13", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", @@ -36697,7 +35378,6 @@ "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", "cross-env": "^10.1.0", - "drizzle-kit": "^0.31.10", "playwright": "^1.56.1", "tsx": "^4.6.0", "typescript": "^5.2.2", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index dd4772dbe..7269b9949 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -5,10 +5,9 @@ "description": "Paseo desktop app (Electron wrapper)", "main": "dist/main.js", "scripts": { - "build": "npm --prefix ../.. run build:daemon && npm run build:main && npm run rebuild-native && electron-builder --config electron-builder.yml", + "build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml", "build:main": "tsc -p tsconfig.json", "dev": "./scripts/dev.sh", - "rebuild-native": "electron-rebuild -m ../.. -o better-sqlite3", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json" }, @@ -20,7 +19,6 @@ "ws": "^8.14.2" }, "devDependencies": { - "@electron/rebuild": "^4.0.3", "@types/node": "24.6.0", "@types/ws": "^8.5.14", "electron": "41.0.3", diff --git a/packages/server/drizzle.config.ts b/packages/server/drizzle.config.ts deleted file mode 100644 index 9920fcb16..000000000 --- a/packages/server/drizzle.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from "drizzle-kit"; - -export default defineConfig({ - schema: "./packages/server/src/server/db/schema.ts", - out: "./packages/server/src/server/db/migrations", - dialect: "sqlite", - strict: true, - verbose: true, -}); diff --git a/packages/server/package.json b/packages/server/package.json index a8528c34d..1eed52d35 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -31,7 +31,7 @@ "dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts", "dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts", "build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts", - "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.cpSync('src/server/db/migrations','dist/server/server/db/migrations',{recursive:true});\"", + "build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"", "build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"", "prepack": "npm run build", "start": "cross-env NODE_ENV=production node dist/server/server/index.js", @@ -41,7 +41,6 @@ "speech:download": "tsx scripts/download-speech-models.ts", "speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", - "db:query": "tsx scripts/db-query.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"", "test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts", @@ -71,8 +70,6 @@ "ai": "5.0.78", "ajv": "^8.17.1", "dotenv": "^17.2.3", - "better-sqlite3": "^12.8.0", - "drizzle-orm": "^0.45.1", "express": "^4.18.2", "express-basic-auth": "^1.2.1", "fast-deep-equal": "^3.1.3", @@ -98,8 +95,6 @@ }, "devDependencies": { "@playwright/test": "^1.56.1", - "@types/better-sqlite3": "^7.6.13", - "drizzle-kit": "^0.31.10", "@types/express": "^4.17.20", "@types/node": "^20.9.0", "@types/qrcode": "^1.5.6", diff --git a/packages/server/scripts/db-query.ts b/packages/server/scripts/db-query.ts deleted file mode 100644 index ffa603830..000000000 --- a/packages/server/scripts/db-query.ts +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env npx tsx -/** - * Run arbitrary SQL against the Paseo SQLite database. - * - * Usage: - * npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots" - * npx tsx packages/server/scripts/db-query.ts --db ~/.paseo/db "SELECT count(*) FROM agent_timeline_rows" - * - * Without args, shows table row counts. - */ - -import path from "node:path"; -import os from "node:os"; -import fs from "node:fs"; -import Database from "better-sqlite3"; - -function resolveHomeDirectory(value: string): string { - if (value === "~") { - return os.homedir(); - } - - if (value.startsWith("~/")) { - return path.join(os.homedir(), value.slice(2)); - } - - return value; -} - -function parseListenPort(listen: unknown): number | null { - if (typeof listen !== "string") { - return null; - } - - const portMatch = listen.match(/:(\d+)$/); - return portMatch ? parseInt(portMatch[1]!, 10) : null; -} - -function findDevDatabaseDirectory(): string | null { - const tmpDir = os.tmpdir(); - for (const entry of fs.readdirSync(tmpDir)) { - if (entry.startsWith("paseo-dev.")) { - const configPath = path.join(tmpDir, entry, "config.json"); - if (fs.existsSync(configPath)) { - try { - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); - const dbDir = config.paseoHome ? path.join(config.paseoHome, "db") : null; - const port = parseListenPort(config.daemon?.listen); - if (dbDir && port === 6767) { - return dbDir; - } - } catch {} - } - } - } - - return null; -} - -function resolveDatabasePath(explicitPath?: string): string { - if (explicitPath) { - const resolvedPath = path.resolve(resolveHomeDirectory(explicitPath)); - return fs.statSync(resolvedPath).isDirectory() - ? path.join(resolvedPath, "paseo.sqlite") - : resolvedPath; - } - - const detectedDevDir = findDevDatabaseDirectory(); - if (detectedDevDir) { - return path.join(detectedDevDir, "paseo.sqlite"); - } - - const paseoHome = process.env.PASEO_HOME - ? path.resolve(resolveHomeDirectory(process.env.PASEO_HOME)) - : path.join(os.homedir(), ".paseo"); - return path.join(paseoHome, "db", "paseo.sqlite"); -} - -async function main() { - const args = process.argv.slice(2); - let dbPath: string | undefined; - const queries: string[] = []; - - for (let i = 0; i < args.length; i++) { - if (args[i] === "--db" && args[i + 1]) { - dbPath = args[++i]; - } else { - queries.push(args[i]!); - } - } - - if (queries.length === 0) { - queries.push( - "SELECT 'agent_snapshots' AS table_name, count(*) AS rows FROM agent_snapshots UNION ALL " + - "SELECT 'agent_timeline_rows', count(*) FROM agent_timeline_rows UNION ALL " + - "SELECT 'projects', count(*) FROM projects UNION ALL " + - "SELECT 'workspaces', count(*) FROM workspaces " + - "ORDER BY table_name", - ); - } - - let databasePath = ""; - let client: Database.Database | null = null; - - try { - if (dbPath) { - const resolvedDbPath = path.resolve(resolveHomeDirectory(dbPath)); - databasePath = - fs.existsSync(resolvedDbPath) && fs.statSync(resolvedDbPath).isDirectory() - ? path.join(resolvedDbPath, "paseo.sqlite") - : resolvedDbPath; - } else { - databasePath = resolveDatabasePath(); - } - - client = new Database(databasePath, { readonly: true, fileMustExist: true }); - - for (const sql of queries) { - const statement = client.prepare(sql); - if (statement.reader) { - const rows = statement.all(); - if (rows.length === 0) { - console.log("(0 rows)\n"); - } else { - console.table(rows); - } - continue; - } - - const result = statement.run(); - console.log(`OK (${result.changes} changes)\n`); - } - } catch (err: any) { - console.error(`Error: ${err.message}\nDatabase: ${databasePath}`); - process.exitCode = 1; - } finally { - client?.close(); - } -} - -main(); diff --git a/packages/server/src/server/agent-loading-service.ts b/packages/server/src/server/agent-loading-service.ts index c24abd85f..e15985149 100644 --- a/packages/server/src/server/agent-loading-service.ts +++ b/packages/server/src/server/agent-loading-service.ts @@ -3,7 +3,7 @@ import type pino from "pino"; import type { ManagedAgent } from "./agent/agent-manager.js"; import type { AgentManager } from "./agent/agent-manager.js"; import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js"; -import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; import { buildConfigOverrides, buildSessionConfig, @@ -18,7 +18,7 @@ export type AgentLoadingServiceOptions = { AgentManager, "createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence" >; - agentStorage: Pick; + agentStorage: Pick; logger: pino.Logger; }; diff --git a/packages/server/src/server/agent/agent-management-mcp.ts b/packages/server/src/server/agent/agent-management-mcp.ts index 81421d1ad..8134f8594 100644 --- a/packages/server/src/server/agent/agent-management-mcp.ts +++ b/packages/server/src/server/agent/agent-management-mcp.ts @@ -35,7 +35,7 @@ import { } from "../messages.js"; import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import type { AgentStorage } from "./agent-storage.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -64,7 +64,7 @@ import { export interface AgentManagementMcpOptions { agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; terminalManager?: TerminalManager | null; getDaemonTcpPort?: () => number | null; scheduleService?: ScheduleService | null; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 0efa776f3..6f4267f8b 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5,10 +5,6 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js"; -import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js"; -import { projects, workspaces } from "../db/schema.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import type { @@ -57,37 +53,6 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age }; } -async function seedWorkspace( - database: PaseoDatabaseHandle, - options: { directory: string }, -): Promise { - const [project] = await database.db - .insert(projects) - .values({ - directory: options.directory, - kind: "git", - displayName: "project-1", - gitRemote: null, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning(); - const [workspace] = await database.db - .insert(workspaces) - .values({ - projectId: project.id, - directory: options.directory, - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning(); - return workspace.id; -} - class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; @@ -950,209 +915,64 @@ describe("AgentManager", () => { test("reloadAgentSession preserves current title when config title is unset", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); - const dataDir = join(workdir, "db"); - const database = await openPaseoDatabase(dataDir); - - try { - const workspaceId = await seedWorkspace(database, { directory: workdir }); - const storage = new DbAgentSnapshotStore(database.db); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000126", - }); - - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId }, - ); - await manager.setTitle(snapshot.id, "Generated title"); - - const beforeReload = await storage.get(snapshot.id); - expect(beforeReload?.title).toBe("Generated title"); - expect(beforeReload?.config?.title).toBeUndefined(); - - await manager.reloadAgentSession(snapshot.id); - - const afterReload = await storage.get(snapshot.id); - expect(afterReload?.title).toBe("Generated title"); - expect(afterReload?.config?.title).toBeUndefined(); - } finally { - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } - }); - - test("resumeAgentFromPersistence reads durable helpers without loading committed rows into live memory", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-seed-")); const storagePath = join(workdir, "agents"); - const dataDir = join(workdir, "db"); const storage = new AgentStorage(storagePath, logger); - const database = await openPaseoDatabase(dataDir); - let historyReplayCount = 0; - let manager: AgentManager | null = null; + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000126", + }); - class HistoryReplayProbeSession extends TestAgentSession { - async *streamHistory(): AsyncGenerator { - historyReplayCount += 1; - yield { - type: "timeline", - provider: this.provider, - item: { type: "assistant_message", text: "provider history replay" }, - }; - } - } + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + await manager.setTitle(snapshot.id, "Generated title"); - class HistoryReplayProbeClient implements AgentClient { - readonly provider = "codex" as const; - readonly capabilities = TEST_CAPABILITIES; + const beforeReload = await storage.get(snapshot.id); + expect(beforeReload?.title).toBe("Generated title"); + expect(beforeReload?.config?.title).toBeUndefined(); - async isAvailable(): Promise { - return true; - } + await manager.reloadAgentSession(snapshot.id); - async createSession(config: AgentSessionConfig): Promise { - return new HistoryReplayProbeSession(config); - } - - async resumeSession( - handle: AgentPersistenceHandle, - overrides?: Partial, - ): Promise { - const metadata = (handle.metadata ?? {}) as Partial; - return new HistoryReplayProbeSession({ - ...metadata, - ...overrides, - provider: "codex", - cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), - }); - } - } - - try { - const durableTimelineStore = new DbAgentTimelineStore(database.db); - manager = new AgentManager({ - clients: { - codex: new HistoryReplayProbeClient(), - }, - registry: storage, - durableTimelineStore, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000128", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - await manager.appendTimelineItem(snapshot.id, { - type: "assistant_message", - text: "durable only", - }); - await manager.flush(); - - const handle = manager.getAgent(snapshot.id)?.persistence; - expect(handle).not.toBeNull(); - if (!handle) { - throw new Error("Expected persistence handle to be available"); - } - - await manager.closeAgent(snapshot.id); - - await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "durable only", - }, - }, - ]); - - const resumed = await manager.resumeAgentFromPersistence(handle, undefined, snapshot.id); - - expect(resumed.id).toBe(snapshot.id); - expect(manager.getTimeline(snapshot.id)).toEqual([]); - await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only"); - await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([ - { - seq: 1, - timestamp: expect.any(String), - item: { - type: "assistant_message", - text: "durable only", - }, - }, - ]); - - await manager.hydrateTimelineFromProvider(snapshot.id); - - expect(historyReplayCount).toBe(0); - expect(manager.getTimeline(snapshot.id)).toEqual([]); - - await manager.closeAgent(snapshot.id); - await manager.deleteCommittedTimeline(snapshot.id); - - await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([]); - } finally { - await manager?.flush().catch(() => undefined); - await storage.flush().catch(() => undefined); - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } + const afterReload = await storage.get(snapshot.id); + expect(afterReload?.title).toBe("Generated title"); + expect(afterReload?.config?.title).toBeUndefined(); }); test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-")); - const dataDir = join(workdir, "db"); - const database = await openPaseoDatabase(dataDir); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000127", + }); - try { - const workspaceId = await seedWorkspace(database, { directory: workdir }); - const storage = new DbAgentSnapshotStore(database.db); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000127", - }); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - }, - undefined, - { workspaceId }, - ); + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); - const before = await storage.get(snapshot.id); - expect(before).not.toBeNull(); + await manager.setTitle(snapshot.id, "Generated title"); - await manager.setTitle(snapshot.id, "Generated title"); + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); + expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); - const after = await storage.get(snapshot.id); - expect(after?.title).toBe("Generated title"); - expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); - - const live = manager.getAgent(snapshot.id); - expect(live).not.toBeNull(); - expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); - } finally { - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } + const live = manager.getAgent(snapshot.id); + expect(live).not.toBeNull(); + expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { @@ -1509,67 +1329,6 @@ describe("AgentManager", () => { }); }); - test("fetchTimeline and getTimelineRows prefer the durable store while live helpers stay in-memory", async () => { - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-read-authority-")); - const storagePath = join(workdir, "agents"); - const dataDir = join(workdir, "db"); - const storage = new AgentStorage(storagePath, logger); - const database = await openPaseoDatabase(dataDir); - - try { - const durableTimelineStore = new DbAgentTimelineStore(database.db); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - durableTimelineStore, - logger, - idFactory: () => "00000000-0000-4000-8000-000000000139", - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workdir, - }); - - const durableOnlyItem: AgentTimelineItem = { - type: "assistant_message", - text: "durable only", - }; - const durableOnlyRow = { - seq: 1, - timestamp: "2026-03-24T00:00:01.000Z", - item: durableOnlyItem, - }; - - await durableTimelineStore.bulkInsert(snapshot.id, [durableOnlyRow]); - - expect(manager.getTimeline(snapshot.id)).toEqual([]); - await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only"); - await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([durableOnlyRow]); - await expect( - manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }), - ).resolves.toEqual({ - direction: "tail", - window: { - minSeq: 1, - maxSeq: 1, - nextSeq: 2, - }, - hasOlder: false, - hasNewer: false, - rows: [durableOnlyRow], - }); - } finally { - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } - }); - test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-")); const storagePath = join(workdir, "agents"); @@ -2079,41 +1838,29 @@ describe("AgentManager", () => { test("createAgent persists provided title before returning", async () => { const agentId = "00000000-0000-4000-8000-000000000102"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); - const dataDir = join(workdir, "db"); - const database = await openPaseoDatabase(dataDir); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => agentId, + }); - try { - const workspaceId = await seedWorkspace(database, { directory: workdir }); - const storage = new DbAgentSnapshotStore(database.db); - const manager = new AgentManager({ - clients: { - codex: new TestAgentClient(), - }, - registry: storage, - logger, - idFactory: () => agentId, - }); + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Fix Login Bug", + }); - const snapshot = await manager.createAgent( - { - provider: "codex", - cwd: workdir, - title: "Fix Login Bug", - }, - undefined, - { workspaceId }, - ); + expect(snapshot.id).toBe(agentId); + expect(snapshot.lifecycle).toBe("idle"); - expect(snapshot.id).toBe(agentId); - expect(snapshot.lifecycle).toBe("idle"); - - const persisted = await storage.get(agentId); - expect(persisted?.title).toBe("Fix Login Bug"); - expect(persisted?.id).toBe(agentId); - } finally { - await database.close(); - rmSync(workdir, { recursive: true, force: true }); - } + const persisted = await storage.get(agentId); + expect(persisted?.title).toBe("Fix Login Bug"); + expect(persisted?.id).toBe(agentId); }); test("createAgent populates runtimeInfo after session creation", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4071039ba..c0dbd729d 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -33,8 +33,7 @@ import type { ListPersistedAgentsOptions, PersistedAgentDescriptor, } from "./agent-sdk-types.js"; -import type { StoredAgentRecord } from "./agent-storage.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import { InMemoryAgentTimelineStore, type SeedAgentTimelineOptions, @@ -92,7 +91,7 @@ export type ProviderAvailability = { export type AgentManagerOptions = { clients?: Partial>; idFactory?: () => string; - registry?: AgentSnapshotStore; + registry?: AgentStorage; onAgentAttention?: AgentAttentionCallback; durableTimelineStore?: AgentTimelineStore; terminalManager?: TerminalManager | null; @@ -306,7 +305,7 @@ export class AgentManager { private readonly pendingForegroundRuns = new Map(); private readonly subscribers = new Set(); private readonly idFactory: () => string; - private readonly registry?: AgentSnapshotStore; + private readonly registry?: AgentStorage; private readonly durableTimelineStore?: AgentTimelineStore; private readonly previousStatuses = new Map(); private readonly backgroundTasks = new Set>(); @@ -605,7 +604,7 @@ export class AgentManager { agentId?: string, options?: { labels?: Record; - workspaceId?: number; + workspaceId?: string; initialPrompt?: string; }, ): Promise { @@ -1812,7 +1811,7 @@ export class AgentManager { config: AgentSessionConfig, agentId: string, options?: { - workspaceId?: number; + workspaceId?: string; createdAt?: Date; updatedAt?: Date; lastUserMessageAt?: Date | null; @@ -2093,7 +2092,7 @@ export class AgentManager { private async persistSnapshot( agent: ManagedAgent, - options?: { workspaceId?: number; title?: string | null; internal?: boolean }, + options?: { workspaceId?: string; title?: string | null; internal?: boolean }, ): Promise { if (!this.registry) { return; @@ -2109,7 +2108,7 @@ export class AgentManager { await this.registry.applySnapshot(agent, options); } - private requireRegistry(): AgentSnapshotStore { + private requireRegistry(): AgentStorage { if (!this.registry) { throw new Error("Agent storage unavailable"); } diff --git a/packages/server/src/server/agent/agent-snapshot-store.ts b/packages/server/src/server/agent/agent-snapshot-store.ts deleted file mode 100644 index ffa3c27bd..000000000 --- a/packages/server/src/server/agent/agent-snapshot-store.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { ManagedAgent } from "./agent-manager.js"; -import type { StoredAgentRecord } from "./agent-storage.js"; - -export interface AgentSnapshotStore { - list(): Promise; - get(agentId: string): Promise; - upsert(record: StoredAgentRecord): Promise; - remove(agentId: string): Promise; - applySnapshot( - agent: ManagedAgent, - options?: { title?: string | null; internal?: boolean }, - ): Promise; - applySnapshot( - agent: ManagedAgent, - workspaceId: number, - options?: { title?: string | null; internal?: boolean }, - ): Promise; - setTitle(agentId: string, title: string): Promise; -} diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 20a6a8643..95ba4325d 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -7,7 +7,6 @@ import type { Logger } from "pino"; import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js"; import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; const SERIALIZABLE_CONFIG_SCHEMA = z @@ -84,7 +83,7 @@ export function parseStoredAgentRecord(value: unknown): StoredAgentRecord { return STORED_AGENT_SCHEMA.parse(value); } -export class AgentStorage implements AgentSnapshotStore { +export class AgentStorage { private cache: Map = new Map(); private pathById: Map = new Map(); private pathsById: Map> = new Map(); @@ -182,10 +181,10 @@ export class AgentStorage implements AgentSnapshotStore { async applySnapshot( agent: ManagedAgent, - workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean }, + workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean }, options?: { title?: string | null; internal?: boolean }, ): Promise { - const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; + const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions; await this.load(); await this.waitForPendingWrite(agent.id); const existing = (await this.get(agent.id)) ?? null; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 4bc477635..f2394be1b 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -6,12 +6,12 @@ import { tmpdir } from "node:os"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { createAgentMcpServer } from "./mcp-server.js"; import type { AgentManager, ManagedAgent } from "./agent-manager.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import type { AgentStorage } from "./agent-storage.js"; import type { ProviderDefinition } from "./provider-registry.js"; type TestDeps = { agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; spies: { agentManager: Record; agentStorage: Record; @@ -46,7 +46,7 @@ function createTestDeps(): TestDeps { return { agentManager: agentManagerSpies as unknown as AgentManager, - agentStorage: agentStorageSpies as unknown as AgentSnapshotStore, + agentStorage: agentStorageSpies as unknown as AgentStorage, spies: { agentManager: agentManagerSpies, agentStorage: agentStorageSpies, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index fdcbca344..75b5bbaed 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -14,7 +14,7 @@ import { } from "../messages.js"; import { toAgentPayload } from "./agent-projections.js"; import { curateAgentActivity } from "./activity-curator.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import type { AgentStorage } from "./agent-storage.js"; import { appendTimelineItemIfAgentKnown, emitLiveTimelineItemIfAgentKnown, @@ -47,7 +47,7 @@ import { export interface AgentMcpServerOptions { agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; terminalManager?: TerminalManager | null; getDaemonTcpPort?: () => number | null; scheduleService?: ScheduleService | null; diff --git a/packages/server/src/server/agent/mcp-shared.ts b/packages/server/src/server/agent/mcp-shared.ts index b818bd190..79d7172aa 100644 --- a/packages/server/src/server/agent/mcp-shared.ts +++ b/packages/server/src/server/agent/mcp-shared.ts @@ -4,7 +4,7 @@ import type { Logger } from "pino"; import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js"; import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js"; import { curateAgentActivity } from "./activity-curator.js"; -import type { AgentSnapshotStore } from "./agent-snapshot-store.js"; +import type { AgentStorage } from "./agent-storage.js"; import { serializeAgentSnapshot } from "../messages.js"; import { StoredScheduleSchema } from "../schedule/types.js"; @@ -253,7 +253,7 @@ export function sanitizePermissionRequest( } export async function resolveAgentTitle( - agentStorage: AgentSnapshotStore, + agentStorage: AgentStorage, agentId: string, logger: Logger, ): Promise { @@ -267,7 +267,7 @@ export async function resolveAgentTitle( } export async function serializeSnapshotWithMetadata( - agentStorage: AgentSnapshotStore, + agentStorage: AgentStorage, snapshot: ManagedAgent, logger: Logger, ) { diff --git a/packages/server/src/server/bootstrap.smoke.test.ts b/packages/server/src/server/bootstrap.smoke.test.ts index 6097afc22..ee2b4e520 100644 --- a/packages/server/src/server/bootstrap.smoke.test.ts +++ b/packages/server/src/server/bootstrap.smoke.test.ts @@ -1,7 +1,5 @@ import os from "node:os"; import path from "node:path"; -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; -import { execFileSync } from "node:child_process"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { Writable } from "node:stream"; import pino from "pino"; @@ -10,8 +8,6 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; -import { openPaseoDatabase } from "./db/sqlite-database.js"; -import { agentSnapshots, projects, workspaces } from "./db/schema.js"; describe("paseo daemon bootstrap", () => { afterEach(() => { @@ -203,432 +199,4 @@ describe("paseo daemon bootstrap", () => { await rm(staticDir, { recursive: true, force: true }); } }); - - test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => { - const { config, cleanup } = await createBootstrapConfig(); - const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-")); - initializeGitRepo(projectDir); - writeLegacyProjectWorkspaceJson(config.paseoHome, { - projects: [ - { - projectId: "project-1", - rootPath: projectDir, - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspaces: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: projectDir, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); - - try { - await daemon.start(); - await daemon.stop(); - expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); - const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - try { - const projectRows = await database.db.select().from(projects); - expect(projectRows).toHaveLength(1); - expect(projectRows[0]).toMatchObject({ - directory: projectDir, - kind: "git", - createdAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - const workspaceRows = await database.db.select().from(workspaces); - expect(workspaceRows).toHaveLength(1); - expect(workspaceRows[0]).toMatchObject({ - projectId: projectRows[0]!.id, - directory: projectDir, - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }); - } finally { - await database.close(); - } - } finally { - await rm(projectDir, { recursive: true, force: true }); - await cleanup(); - } - }); - - test("does not duplicate imported legacy JSON across daemon restarts", async () => { - const { config, cleanup } = await createBootstrapConfig(); - const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-")); - initializeGitRepo(projectDir); - writeLegacyProjectWorkspaceJson(config.paseoHome, { - projects: [ - { - projectId: "project-1", - rootPath: projectDir, - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspaces: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: projectDir, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - try { - const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); - await firstDaemon.start(); - await firstDaemon.stop(); - - const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" })); - await secondDaemon.start(); - await secondDaemon.stop(); - - expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); - const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - try { - expect(await database.db.select().from(projects)).toHaveLength(1); - expect(await database.db.select().from(workspaces)).toHaveLength(1); - } finally { - await database.close(); - } - } finally { - await rm(projectDir, { recursive: true, force: true }); - await cleanup(); - } - }); - - test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => { - const { config, cleanup } = await createBootstrapConfig(); - const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-")); - initializeGitRepo(projectDir); - writeLegacyProjectWorkspaceJson(config.paseoHome, { - projects: [ - { - projectId: "project-1", - rootPath: projectDir, - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspaces: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: projectDir, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", { - id: "agent-1", - provider: "codex", - cwd: projectDir, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: "Imported Agent", - labels: {}, - lastStatus: "idle", - lastModeId: "plan", - config: { model: "gpt-5.1-codex-mini", modeId: "plan" }, - runtimeInfo: { - provider: "codex", - sessionId: "session-123", - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - persistence: null, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }); - - try { - const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); - await daemon.start(); - await daemon.stop(); - - expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); - const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - try { - const projectRows = await database.db.select().from(projects); - const workspaceRows = await database.db.select().from(workspaces); - const agentRows = await database.db.select().from(agentSnapshots); - - expect(projectRows).toHaveLength(1); - expect(workspaceRows).toHaveLength(1); - expect(agentRows).toEqual([ - expect.objectContaining({ - agentId: "agent-1", - cwd: projectDir, - workspaceId: workspaceRows[0]!.id, - title: "Imported Agent", - requiresAttention: false, - internal: false, - }), - ]); - } finally { - await database.close(); - } - } finally { - await rm(projectDir, { recursive: true, force: true }); - await cleanup(); - } - }); - - test("imports large legacy agent JSON batches during SQLite bootstrap", async () => { - const { config, cleanup } = await createBootstrapConfig(); - const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-")); - initializeGitRepo(projectDir); - writeLegacyProjectWorkspaceJson(config.paseoHome, { - projects: [ - { - projectId: "project-1", - rootPath: projectDir, - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspaces: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: projectDir, - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - for (let index = 0; index < 150; index += 1) { - writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, { - id: `agent-${index}`, - provider: "codex", - cwd: projectDir, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: `Imported Agent ${index}`, - labels: {}, - lastStatus: "idle", - lastModeId: "plan", - config: { model: "gpt-5.1-codex-mini", modeId: "plan" }, - runtimeInfo: { - provider: "codex", - sessionId: `session-${index}`, - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - persistence: null, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - }); - } - - try { - const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); - await daemon.start(); - await daemon.stop(); - - expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); - const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - try { - const projectRows = await database.db.select().from(projects); - const workspaceRows = await database.db.select().from(workspaces); - const agentRows = await database.db.select().from(agentSnapshots); - - expect(projectRows).toHaveLength(1); - expect(workspaceRows).toHaveLength(1); - expect(agentRows).toHaveLength(150); - expect(agentRows[0]?.workspaceId).toBe(workspaceRows[0]!.id); - expect(agentRows.map((row) => row.agentId)).toContain("agent-149"); - } finally { - await database.close(); - } - } finally { - await rm(projectDir, { recursive: true, force: true }); - await cleanup(); - } - }); - - test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => { - const { config, cleanup } = await createBootstrapConfig(); - const agentStorageDir = path.join(config.paseoHome, "agents"); - mkdirSync(agentStorageDir, { recursive: true }); - const storageBucket = path.join(agentStorageDir, "tmp-db-only-project"); - mkdirSync(storageBucket, { recursive: true }); - writeFileSync( - path.join(storageBucket, "agent-1.json"), - JSON.stringify( - { - id: "agent-1", - provider: "codex", - cwd: "/tmp/db-only-project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: null, - config: null, - runtimeInfo: { provider: "codex", sessionId: null }, - persistence: null, - archivedAt: null, - }, - null, - 2, - ), - "utf8", - ); - - try { - const daemon = await createPaseoDaemon(config, pino({ level: "silent" })); - await daemon.start(); - await daemon.stop(); - - expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true); - const database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - try { - expect(await database.db.select().from(agentSnapshots)).toEqual([ - expect.objectContaining({ - agentId: "agent-1", - cwd: "/tmp/db-only-project", - requiresAttention: false, - internal: false, - }), - ]); - expect(await database.db.select().from(projects)).toHaveLength(1); - expect(await database.db.select().from(workspaces)).toHaveLength(1); - } finally { - await database.close(); - } - - expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false); - expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false); - } finally { - await cleanup(); - } - }); }); - -async function createBootstrapConfig(): Promise<{ - config: PaseoDaemonConfig; - cleanup: () => Promise; -}> { - const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-db-")); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-")); - await mkdir(paseoHome, { recursive: true }); - - return { - config: { - listen: "127.0.0.1:0", - paseoHome, - corsAllowedOrigins: [], - allowedHosts: true, - mcpEnabled: false, - staticDir, - mcpDebug: false, - agentClients: createTestAgentClients(), - agentStoragePath: path.join(paseoHome, "agents"), - relayEnabled: false, - appBaseUrl: "https://app.paseo.sh", - openai: undefined, - speech: undefined, - }, - cleanup: async () => { - await rm(paseoHomeRoot, { recursive: true, force: true }); - await rm(staticDir, { recursive: true, force: true }); - }, - }; -} - -function writeLegacyProjectWorkspaceJson( - paseoHome: string, - input: { - projects: unknown[]; - workspaces: unknown[]; - }, -): void { - const projectsDir = path.join(paseoHome, "projects"); - mkdirSync(projectsDir, { recursive: true }); - writeFileSync( - path.join(projectsDir, "projects.json"), - JSON.stringify(input.projects, null, 2), - "utf8", - ); - writeFileSync( - path.join(projectsDir, "workspaces.json"), - JSON.stringify(input.workspaces, null, 2), - "utf8", - ); -} - -function writeLegacyAgentJson( - paseoHome: string, - relativePath: string, - payload: Record, -): void { - const absolutePath = path.join(paseoHome, relativePath); - mkdirSync(path.dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8"); -} - -function initializeGitRepo(directory: string): void { - execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" }); - execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], { - cwd: directory, - stdio: "pipe", - }); - execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" }); - writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" }); - execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "init"], { - cwd: directory, - stdio: "pipe", - }); -} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 74cd7c967..67a22ddae 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -93,21 +93,16 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config. import type { RequestedSpeechProviders } from "./speech/speech-types.js"; import { createSpeechService } from "./speech/speech-runtime.js"; import { AgentManager } from "./agent/agent-manager.js"; -import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; +import { AgentStorage } from "./agent/agent-storage.js"; +import { attachAgentStoragePersistence } from "./persistence-hooks.js"; import { createAgentMcpServer } from "./agent/mcp-server.js"; import { buildProviderRegistry, createAllClients, shutdownProviders, } from "./agent/provider-registry.js"; -import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js"; -import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js"; -import { DbProjectRegistry } from "./db/db-project-registry.js"; -import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js"; -import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js"; -import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js"; -import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js"; +import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { FileBackedChatService } from "./chat/chat-service.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; import { LoopService } from "./loop-service.js"; @@ -201,7 +196,7 @@ export type PaseoDaemonConfig = { export interface PaseoDaemon { config: PaseoDaemonConfig; agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; terminalManager: TerminalManager; scriptRouteStore: ScriptRouteStore; scriptRuntimeStore: WorkspaceScriptRuntimeStore; @@ -218,7 +213,6 @@ export async function createPaseoDaemon( const bootstrapStart = performance.now(); const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`; const daemonVersion = resolveDaemonVersion(import.meta.url); - let database: PaseoDatabaseHandle | null = null; const daemonConfigStore = new DaemonConfigStore( config.paseoHome, { @@ -384,9 +378,6 @@ export async function createPaseoDaemon( const httpServer = createHTTPServer(app); - database = await openPaseoDatabase(path.join(config.paseoHome, "db")); - logger.info({ elapsed: elapsed() }, "Paseo database opened"); - // Script proxy WebSocket upgrade handler — must be registered before the // VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that // script-bound upgrades are forwarded first. The handler is a no-op for @@ -397,15 +388,20 @@ export async function createPaseoDaemon( }); httpServer.on("upgrade", scriptProxyUpgradeHandler); - const agentStorage = new DbAgentSnapshotStore(database.db); + const agentStorage = new AgentStorage(config.agentStoragePath, logger); + const projectRegistry = new FileBackedProjectRegistry( + path.join(config.paseoHome, "projects", "projects.json"), + logger, + ); + const workspaceRegistry = new FileBackedWorkspaceRegistry( + path.join(config.paseoHome, "projects", "workspaces.json"), + logger, + ); const chatService = new FileBackedChatService({ paseoHome: config.paseoHome, logger, }); - const durableTimelineStore = new DbAgentTimelineStore(database.db); - let agentManager: AgentManager | null = null; - const terminalManager = createTerminalManager(); - agentManager = new AgentManager({ + const agentManager = new AgentManager({ clients: { ...createAllClients(logger, { runtimeSettings: config.agentProviderSettings, @@ -414,8 +410,6 @@ export async function createPaseoDaemon( ...config.agentClients, }, registry: agentStorage, - durableTimelineStore, - terminalManager, logger, }); const providerRegistry = buildProviderRegistry(logger, { @@ -423,37 +417,23 @@ export async function createPaseoDaemon( providerOverrides: config.providerOverrides, }); - const projectRegistry = new DbProjectRegistry(database.db); - const workspaceRegistry = new DbWorkspaceRegistry(database.db); + const terminalManager = createTerminalManager(); - try { - await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome: config.paseoHome, - logger, - }); - logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked"); - } catch (err) { - logger.error({ err }, "Legacy project/workspace import failed (non-fatal)"); - } - try { - await importLegacyAgentSnapshots({ - db: database.db, - paseoHome: config.paseoHome, - logger, - }); - logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked"); - } catch (err) { - logger.error({ err }, "Legacy agent snapshot import failed (non-fatal)"); - } - - const reconciliationService = new WorkspaceReconciliationService({ + const detachAgentStoragePersistence = attachAgentStoragePersistence( + logger, + agentManager, + agentStorage, + ); + await agentStorage.initialize(); + logger.info({ elapsed: elapsed() }, "Agent storage initialized"); + await bootstrapWorkspaceRegistries({ + paseoHome: config.paseoHome, + agentStorage, projectRegistry, workspaceRegistry, logger, }); - reconciliationService.start(); - logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started"); + logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped"); await chatService.initialize(); logger.info({ elapsed: elapsed() }, "Chat service initialized"); const checkoutDiffManager = new CheckoutDiffManager({ @@ -755,10 +735,11 @@ export async function createPaseoDaemon( }; const stop = async () => { - reconciliationService.stop(); scriptHealthMonitor.stop(); await closeAllAgents(logger, agentManager); await agentManager.flush().catch(() => undefined); + detachAgentStoragePersistence(); + await agentStorage.flush().catch(() => undefined); await shutdownProviders(logger, { runtimeSettings: config.agentProviderSettings, providerOverrides: config.providerOverrides, @@ -770,7 +751,6 @@ export async function createPaseoDaemon( if (wsServer) { await wsServer.close(); } - await database?.close().catch(() => undefined); await new Promise((resolve) => { httpServer.close(() => resolve()); }); @@ -792,7 +772,6 @@ export async function createPaseoDaemon( getListenTarget: () => boundListenTarget, }; } catch (err) { - await database?.close().catch(() => undefined); throw err; } } diff --git a/packages/server/src/server/db/db-agent-snapshot-store.test.ts b/packages/server/src/server/db/db-agent-snapshot-store.test.ts deleted file mode 100644 index 52771d392..000000000 --- a/packages/server/src/server/db/db-agent-snapshot-store.test.ts +++ /dev/null @@ -1,285 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import type { ManagedAgent } from "../agent/agent-manager.js"; -import type { - AgentPermissionRequest, - AgentSession, - AgentSessionConfig, -} from "../agent/agent-sdk-types.js"; -import type { StoredAgentRecord } from "../agent/agent-storage.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js"; -import { agentSnapshots, projects, workspaces } from "./schema.js"; - -type ManagedAgentOverrides = Omit< - Partial, - "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" -> & { - config?: Partial; - pendingPermissions?: Map; - session?: AgentSession | null; - activeForegroundTurnId?: string | null; - runtimeInfo?: ManagedAgent["runtimeInfo"]; - attention?: ManagedAgent["attention"]; -}; - -function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { - const now = overrides.updatedAt ?? new Date("2026-03-01T00:00:00.000Z"); - const provider = overrides.provider ?? "codex"; - const cwd = overrides.cwd ?? "/tmp/project"; - const lifecycle = overrides.lifecycle ?? "idle"; - const configOverrides = overrides.config ?? {}; - const config: AgentSessionConfig = { - provider, - cwd, - title: configOverrides.title, - modeId: configOverrides.modeId ?? "plan", - model: configOverrides.model ?? "gpt-5.1-codex-mini", - extra: configOverrides.extra ?? { codex: { approvalPolicy: "on-request" } }, - systemPrompt: configOverrides.systemPrompt, - mcpServers: configOverrides.mcpServers, - }; - const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); - const activeForegroundTurnId = - overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "turn-1" : null); - - return { - id: overrides.id ?? "agent-1", - provider, - cwd, - session, - capabilities: overrides.capabilities ?? { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - config, - lifecycle, - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, - availableModes: overrides.availableModes ?? [], - currentModeId: overrides.currentModeId ?? config.modeId ?? null, - pendingPermissions: overrides.pendingPermissions ?? new Map(), - activeForegroundTurnId, - foregroundTurnWaiters: new Set(), - unsubscribeSession: null, - timeline: overrides.timeline ?? [], - attention: overrides.attention ?? { requiresAttention: false }, - runtimeInfo: overrides.runtimeInfo ?? { - provider, - sessionId: overrides.sessionId ?? "session-123", - model: config.model ?? null, - modeId: config.modeId ?? null, - }, - persistence: overrides.persistence ?? null, - historyPrimed: overrides.historyPrimed ?? true, - lastUserMessageAt: overrides.lastUserMessageAt ?? now, - lastUsage: overrides.lastUsage, - lastError: overrides.lastError, - internal: overrides.internal, - labels: overrides.labels ?? {}, - pendingReplacement: false, - provisionalAssistantText: null, - }; -} - -function createStoredAgentRecord(overrides: Partial = {}): StoredAgentRecord { - return { - id: "agent-1", - provider: "codex", - cwd: "/tmp/project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - lastActivityAt: "2026-03-01T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: "plan", - config: { - modeId: "plan", - model: "gpt-5.1-codex-mini", - }, - runtimeInfo: { - provider: "codex", - sessionId: "session-123", - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - persistence: { - provider: "codex", - sessionId: "session-123", - }, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: null, - ...overrides, - }; -} - -describe("DbAgentSnapshotStore", () => { - let tmpDir: string; - let dataDir: string; - let database: PaseoDatabaseHandle; - let store: DbAgentSnapshotStore; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-snapshot-store-")); - dataDir = path.join(tmpDir, "db"); - database = await openPaseoDatabase(dataDir); - store = new DbAgentSnapshotStore(database.db); - }); - - afterEach(async () => { - await database.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("supports list/get/upsert/remove CRUD lifecycle", async () => { - const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); - - const record = createStoredAgentRecord(); - - expect(await store.list()).toEqual([]); - expect(await store.get(record.id)).toBeNull(); - - await store.upsert(record, workspaceId); - - expect(await store.get(record.id)).toEqual(record); - expect(await store.list()).toEqual([record]); - expect(await database.db.select().from(agentSnapshots)).toEqual([ - expect.objectContaining({ - agentId: "agent-1", - workspaceId, - requiresAttention: false, - internal: false, - }), - ]); - - await store.remove(record.id); - - expect(await store.get(record.id)).toBeNull(); - expect(await store.list()).toEqual([]); - }); - - test("applySnapshot preserves title, createdAt, and archivedAt across updates", async () => { - const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); - - await store.upsert( - createStoredAgentRecord({ - id: "agent-apply", - title: "Pinned title", - createdAt: "2026-03-01T00:00:00.000Z", - archivedAt: "2026-03-05T00:00:00.000Z", - }), - workspaceId, - ); - - await store.applySnapshot( - createManagedAgent({ - id: "agent-apply", - createdAt: new Date("2026-03-10T00:00:00.000Z"), - updatedAt: new Date("2026-03-11T00:00:00.000Z"), - lifecycle: "running", - }), - workspaceId, - ); - - expect(await store.get("agent-apply")).toEqual( - expect.objectContaining({ - id: "agent-apply", - title: "Pinned title", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-11T00:00:00.000Z", - archivedAt: "2026-03-05T00:00:00.000Z", - lastStatus: "running", - }), - ); - }); - - test("setTitle throws for missing agents and updates existing agents", async () => { - const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); - - await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow( - "Agent missing-agent not found", - ); - - await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }), workspaceId); - await store.setTitle("agent-title", "Renamed agent"); - - expect(await store.get("agent-title")).toEqual( - expect.objectContaining({ - id: "agent-title", - title: "Renamed agent", - }), - ); - }); - - test("upsert is idempotent for the same agent ID", async () => { - const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" }); - - await store.upsert( - createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }), - workspaceId, - ); - await store.upsert( - createStoredAgentRecord({ - id: "agent-idempotent", - title: "Updated", - updatedAt: "2026-03-02T00:00:00.000Z", - lastStatus: "running", - }), - workspaceId, - ); - - expect(await store.list()).toEqual([ - createStoredAgentRecord({ - id: "agent-idempotent", - title: "Updated", - updatedAt: "2026-03-02T00:00:00.000Z", - lastStatus: "running", - }), - ]); - expect(await database.db.select().from(agentSnapshots)).toHaveLength(1); - }); -}); - -async function seedWorkspace( - database: PaseoDatabaseHandle, - options: { directory: string }, -): Promise { - const [project] = await database.db - .insert(projects) - .values({ - directory: options.directory, - kind: "git", - displayName: "project-1", - gitRemote: null, - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning(); - const [workspace] = await database.db - .insert(workspaces) - .values({ - projectId: project.id, - directory: options.directory, - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning(); - return workspace.id; -} diff --git a/packages/server/src/server/db/db-agent-snapshot-store.ts b/packages/server/src/server/db/db-agent-snapshot-store.ts deleted file mode 100644 index 6552d85c1..000000000 --- a/packages/server/src/server/db/db-agent-snapshot-store.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { asc, eq } from "drizzle-orm"; - -import type { ManagedAgent } from "../agent/agent-manager.js"; -import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js"; -import { toStoredAgentRecord } from "../agent/agent-projections.js"; -import type { StoredAgentRecord } from "../agent/agent-storage.js"; -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; -import { agentSnapshots } from "./schema.js"; - -type AgentSnapshotRow = typeof agentSnapshots.$inferSelect; -type AgentSnapshotInsert = typeof agentSnapshots.$inferInsert; - -export function toStoredAgentRecordFromRow(row: AgentSnapshotRow): StoredAgentRecord { - return { - id: row.agentId, - provider: row.provider, - cwd: row.cwd, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - lastActivityAt: row.lastActivityAt ?? undefined, - lastUserMessageAt: row.lastUserMessageAt ?? null, - title: row.title ?? null, - labels: row.labels, - lastStatus: row.lastStatus as StoredAgentRecord["lastStatus"], - lastModeId: row.lastModeId ?? null, - config: row.config ?? null, - runtimeInfo: row.runtimeInfo ?? undefined, - persistence: row.persistence ?? null, - requiresAttention: row.requiresAttention, - attentionReason: (row.attentionReason ?? null) as StoredAgentRecord["attentionReason"], - attentionTimestamp: row.attentionTimestamp ?? null, - internal: row.internal, - archivedAt: row.archivedAt ?? null, - }; -} - -export function toAgentSnapshotRowValues(options: { - record: StoredAgentRecord; - workspaceId: number; -}): AgentSnapshotInsert { - const { record, workspaceId } = options; - return { - agentId: record.id, - provider: record.provider, - workspaceId, - cwd: record.cwd, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - lastActivityAt: record.lastActivityAt ?? null, - lastUserMessageAt: record.lastUserMessageAt ?? null, - title: record.title ?? null, - labels: record.labels, - lastStatus: record.lastStatus, - lastModeId: record.lastModeId ?? null, - config: record.config ?? null, - runtimeInfo: record.runtimeInfo ?? null, - persistence: record.persistence ?? null, - requiresAttention: record.requiresAttention ?? false, - attentionReason: record.attentionReason ?? null, - attentionTimestamp: record.attentionTimestamp ?? null, - internal: record.internal ?? false, - archivedAt: record.archivedAt ?? null, - }; -} - -function toAgentSnapshotUpdateSet(values: AgentSnapshotInsert) { - return { - provider: values.provider, - workspaceId: values.workspaceId, - cwd: values.cwd, - createdAt: values.createdAt, - updatedAt: values.updatedAt, - lastActivityAt: values.lastActivityAt, - lastUserMessageAt: values.lastUserMessageAt, - title: values.title, - labels: values.labels, - lastStatus: values.lastStatus, - lastModeId: values.lastModeId, - config: values.config, - runtimeInfo: values.runtimeInfo, - persistence: values.persistence, - requiresAttention: values.requiresAttention, - attentionReason: values.attentionReason, - attentionTimestamp: values.attentionTimestamp, - internal: values.internal, - archivedAt: values.archivedAt, - } satisfies Omit; -} - -export class DbAgentSnapshotStore implements AgentSnapshotStore { - private readonly db: PaseoDatabaseHandle["db"]; - - constructor(db: PaseoDatabaseHandle["db"]) { - this.db = db; - } - - async list(): Promise { - const rows = await this.db - .select() - .from(agentSnapshots) - .orderBy(asc(agentSnapshots.createdAt), asc(agentSnapshots.agentId)); - return rows.map(toStoredAgentRecordFromRow); - } - - async get(agentId: string): Promise { - const rows = await this.db - .select() - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, agentId)) - .limit(1); - const row = rows[0]; - return row ? toStoredAgentRecordFromRow(row) : null; - } - - async upsert(record: StoredAgentRecord): Promise; - async upsert(record: StoredAgentRecord, workspaceId: number): Promise; - async upsert(record: StoredAgentRecord, workspaceId?: number): Promise { - const nextWorkspaceId = - workspaceId ?? - ( - await this.db - .select({ workspaceId: agentSnapshots.workspaceId }) - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, record.id)) - .limit(1) - )[0]?.workspaceId; - if (nextWorkspaceId === undefined) { - throw new Error(`Workspace ID required for agent ${record.id}`); - } - const values = toAgentSnapshotRowValues({ - record, - workspaceId: nextWorkspaceId, - }); - - await this.db - .insert(agentSnapshots) - .values(values) - .onConflictDoUpdate({ - target: agentSnapshots.agentId, - set: toAgentSnapshotUpdateSet(values), - }); - } - - async remove(agentId: string): Promise { - await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId)); - } - - async applySnapshot( - agent: ManagedAgent, - options?: { title?: string | null; internal?: boolean }, - ): Promise; - async applySnapshot( - agent: ManagedAgent, - workspaceId: number, - options?: { title?: string | null; internal?: boolean }, - ): Promise; - async applySnapshot( - agent: ManagedAgent, - workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean }, - options?: { title?: string | null; internal?: boolean }, - ): Promise { - const nextWorkspaceId = - typeof workspaceIdOrOptions === "number" - ? workspaceIdOrOptions - : ( - await this.db - .select({ workspaceId: agentSnapshots.workspaceId }) - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, agent.id)) - .limit(1) - )[0]?.workspaceId; - const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions; - const existing = await this.get(agent.id); - const hasTitleOverride = - nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title"); - const hasInternalOverride = - nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal"); - const record = toStoredAgentRecord(agent, { - title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null), - createdAt: existing?.createdAt, - internal: hasInternalOverride - ? nextOptions?.internal - : (agent.internal ?? existing?.internal), - }); - - if (existing && existing.archivedAt !== undefined) { - record.archivedAt = existing.archivedAt; - } - - if (nextWorkspaceId === undefined) { - return; - } - await this.upsert(record, nextWorkspaceId); - } - - async setTitle(agentId: string, title: string): Promise { - const rows = await this.db - .select() - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, agentId)) - .limit(1); - const row = rows[0]; - if (!row) { - throw new Error(`Agent ${agentId} not found`); - } - await this.upsert({ ...toStoredAgentRecordFromRow(row), title }, row.workspaceId); - } -} diff --git a/packages/server/src/server/db/db-agent-timeline-store.test.ts b/packages/server/src/server/db/db-agent-timeline-store.test.ts deleted file mode 100644 index 65f951672..000000000 --- a/packages/server/src/server/db/db-agent-timeline-store.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { DbAgentTimelineStore } from "./db-agent-timeline-store.js"; -import { agentTimelineRows } from "./schema.js"; - -function createTimestamp(seq: number): string { - return new Date(Date.UTC(2026, 2, 1, 0, 0, seq)).toISOString(); -} - -function createTimelineItem( - type: Extract, - value: string, -): AgentTimelineItem { - if (type === "user_message") { - return { - type, - text: `user-${value}`, - messageId: `message-${value}`, - }; - } - - return { - type, - text: `assistant-${value}`, - }; -} - -function createRow(seq: number, item?: AgentTimelineItem): AgentTimelineRow { - return { - seq, - timestamp: createTimestamp(seq), - item: item ?? createTimelineItem("assistant_message", String(seq)), - }; -} - -describe("DbAgentTimelineStore", () => { - let tmpDir: string; - let dataDir: string; - let database: PaseoDatabaseHandle; - let store: DbAgentTimelineStore; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-agent-timeline-store-")); - dataDir = path.join(tmpDir, "db"); - database = await openPaseoDatabase(dataDir); - store = new DbAgentTimelineStore(database.db); - }); - - afterEach(async () => { - await database.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("appendCommitted assigns sequential seq numbers per agent", async () => { - expect( - await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")), - ).toEqual({ - seq: 1, - timestamp: expect.any(String), - item: createTimelineItem("assistant_message", "1"), - }); - - expect( - await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")), - ).toEqual({ - seq: 2, - timestamp: expect.any(String), - item: createTimelineItem("assistant_message", "2"), - }); - - expect( - await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "3")), - ).toEqual({ - seq: 3, - timestamp: expect.any(String), - item: createTimelineItem("assistant_message", "3"), - }); - }); - - test("appendCommitted for different agents has independent seq sequences", async () => { - const firstAgentFirstRow = await store.appendCommitted( - "agent-1", - createTimelineItem("assistant_message", "a1"), - ); - const secondAgentFirstRow = await store.appendCommitted( - "agent-2", - createTimelineItem("assistant_message", "b1"), - ); - const firstAgentSecondRow = await store.appendCommitted( - "agent-1", - createTimelineItem("assistant_message", "a2"), - ); - - expect(firstAgentFirstRow.seq).toBe(1); - expect(secondAgentFirstRow.seq).toBe(1); - expect(firstAgentSecondRow.seq).toBe(2); - }); - - test("fetchCommitted tail returns the last N rows", async () => { - await store.bulkInsert( - "agent-1", - [1, 2, 3, 4, 5].map((seq) => createRow(seq)), - ); - - await expect( - store.fetchCommitted("agent-1", { - direction: "tail", - limit: 2, - }), - ).resolves.toEqual({ - direction: "tail", - window: { - minSeq: 1, - maxSeq: 5, - nextSeq: 6, - }, - hasOlder: true, - hasNewer: false, - rows: [createRow(4), createRow(5)], - }); - }); - - test("fetchCommitted after-cursor returns rows after a given seq", async () => { - await store.bulkInsert( - "agent-1", - [1, 2, 3, 4, 5].map((seq) => createRow(seq)), - ); - - await expect( - store.fetchCommitted("agent-1", { - direction: "after", - cursor: { seq: 2 }, - limit: 2, - }), - ).resolves.toEqual({ - direction: "after", - window: { - minSeq: 1, - maxSeq: 5, - nextSeq: 6, - }, - hasOlder: true, - hasNewer: true, - rows: [createRow(3), createRow(4)], - }); - }); - - test("fetchCommitted before-cursor returns rows before a given seq", async () => { - await store.bulkInsert( - "agent-1", - [1, 2, 3, 4, 5].map((seq) => createRow(seq)), - ); - - await expect( - store.fetchCommitted("agent-1", { - direction: "before", - cursor: { seq: 4 }, - limit: 2, - }), - ).resolves.toEqual({ - direction: "before", - window: { - minSeq: 1, - maxSeq: 5, - nextSeq: 6, - }, - hasOlder: true, - hasNewer: true, - rows: [createRow(2), createRow(3)], - }); - }); - - test("getLatestCommittedSeq returns 0 for an unknown agent", async () => { - await expect(store.getLatestCommittedSeq("missing-agent")).resolves.toBe(0); - }); - - test("getLatestCommittedSeq returns the latest seq after appends", async () => { - await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "1")); - await store.appendCommitted("agent-1", createTimelineItem("assistant_message", "2")); - - await expect(store.getLatestCommittedSeq("agent-1")).resolves.toBe(2); - }); - - test("deleteAgent removes all rows for the target agent", async () => { - await store.bulkInsert("agent-1", [createRow(1), createRow(2)]); - await store.bulkInsert("agent-2", [createRow(1)]); - - await store.deleteAgent("agent-1"); - - await expect(store.getCommittedRows("agent-1")).resolves.toEqual([]); - await expect(store.getCommittedRows("agent-2")).resolves.toEqual([createRow(1)]); - }); - - test("bulkInsert preserves provided seq numbers", async () => { - const rows = [createRow(3), createRow(7)]; - - await store.bulkInsert("agent-1", rows); - - await expect(store.getCommittedRows("agent-1")).resolves.toEqual(rows); - }); - - test("item_kind is populated from item.type", async () => { - await store.appendCommitted("agent-1", createTimelineItem("user_message", "kind-check"), { - timestamp: createTimestamp(1), - }); - - await expect(database.db.select().from(agentTimelineRows)).resolves.toEqual([ - expect.objectContaining({ - agentId: "agent-1", - seq: 1, - committedAt: createTimestamp(1), - itemKind: "user_message", - }), - ]); - }); - - test("getLastItem returns the latest committed item", async () => { - await store.bulkInsert("agent-1", [ - createRow(1, createTimelineItem("user_message", "1")), - createRow(2, createTimelineItem("assistant_message", "2")), - ]); - - await expect(store.getLastItem("agent-1")).resolves.toEqual( - createTimelineItem("assistant_message", "2"), - ); - await expect(store.getLastItem("missing-agent")).resolves.toBeNull(); - }); - - test("getLastAssistantMessage assembles the latest contiguous assistant chunks", async () => { - await store.bulkInsert("agent-1", [ - createRow(1, createTimelineItem("assistant_message", "1")), - createRow(2, createTimelineItem("assistant_message", "2")), - createRow(3, { type: "reasoning", text: "separator-1" }), - createRow(4, createTimelineItem("assistant_message", "4")), - createRow(5, createTimelineItem("assistant_message", "5")), - createRow(6, { type: "reasoning", text: "separator-2" }), - ]); - - await expect(store.getLastAssistantMessage("agent-1")).resolves.toBe("assistant-4assistant-5"); - await expect(store.getLastAssistantMessage("missing-agent")).resolves.toBeNull(); - }); - - test("hasCommittedUserMessage matches by normalized messageId and text", async () => { - await store.bulkInsert("agent-1", [ - createRow(1, createTimelineItem("user_message", "1")), - createRow(2, createTimelineItem("assistant_message", "2")), - ]); - - await expect( - store.hasCommittedUserMessage("agent-1", { - messageId: " message-1 ", - text: "user-1", - }), - ).resolves.toBe(true); - await expect( - store.hasCommittedUserMessage("agent-1", { - messageId: "message-1", - text: "different", - }), - ).resolves.toBe(false); - await expect( - store.hasCommittedUserMessage("agent-1", { - messageId: " ", - text: "user-1", - }), - ).resolves.toBe(false); - }); -}); diff --git a/packages/server/src/server/db/db-agent-timeline-store.ts b/packages/server/src/server/db/db-agent-timeline-store.ts deleted file mode 100644 index 25e6a96f8..000000000 --- a/packages/server/src/server/db/db-agent-timeline-store.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; - -import type { - AgentTimelineFetchOptions, - AgentTimelineFetchResult, - AgentTimelineRow, - AgentTimelineStore, - AgentTimelineWindow, -} from "../agent/agent-timeline-store-types.js"; -import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; -import { agentTimelineRows } from "./schema.js"; - -type AgentTimelineRowRecord = typeof agentTimelineRows.$inferSelect; -type AgentTimelineRowInsert = typeof agentTimelineRows.$inferInsert; - -const DEFAULT_TIMELINE_FETCH_LIMIT = 200; - -function normalizeTimelineMessageId(messageId: string | undefined): string | undefined { - if (typeof messageId !== "string") { - return undefined; - } - const normalized = messageId.trim(); - return normalized.length > 0 ? normalized : undefined; -} - -function toTimelineRow(row: AgentTimelineRowRecord): AgentTimelineRow { - return { - seq: row.seq, - timestamp: row.committedAt, - item: row.item, - }; -} - -function toInsertValues(agentId: string, row: AgentTimelineRow): AgentTimelineRowInsert { - return { - agentId, - seq: row.seq, - committedAt: row.timestamp, - item: row.item, - itemKind: row.item.type, - }; -} - -function normalizeFetchLimit(limit: number | undefined): number { - if (limit === undefined) { - return DEFAULT_TIMELINE_FETCH_LIMIT; - } - return Math.max(0, Math.floor(limit)); -} - -export class DbAgentTimelineStore implements AgentTimelineStore { - private readonly db: PaseoDatabaseHandle["db"]; - - constructor(db: PaseoDatabaseHandle["db"]) { - this.db = db; - } - - async appendCommitted( - agentId: string, - item: AgentTimelineItem, - options?: { timestamp?: string }, - ): Promise { - const nextSeq = (await this.getMaxSeq(agentId)) + 1; - const row: AgentTimelineRow = { - seq: nextSeq, - timestamp: options?.timestamp ?? new Date().toISOString(), - item, - }; - - await this.db.insert(agentTimelineRows).values(toInsertValues(agentId, row)); - return row; - } - - async fetchCommitted( - agentId: string, - options?: AgentTimelineFetchOptions, - ): Promise { - const direction = options?.direction ?? "tail"; - const limit = normalizeFetchLimit(options?.limit); - const selectAll = limit === 0; - const window = await this.getWindow(agentId); - - if (window.maxSeq === 0) { - return { - direction, - window, - hasOlder: false, - hasNewer: false, - rows: [], - }; - } - - if (direction === "tail") { - const rows = selectAll - ? await this.db - .select() - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)) - .orderBy(asc(agentTimelineRows.seq)) - : ( - await this.db - .select() - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)) - .orderBy(desc(agentTimelineRows.seq)) - .limit(limit) - ).reverse(); - const selected = rows.map(toTimelineRow); - return { - direction, - window, - hasOlder: selected.length > 0 && selected[0]!.seq > window.minSeq, - hasNewer: false, - rows: selected, - }; - } - - if (direction === "after") { - const baseSeq = options?.cursor?.seq ?? 0; - const rows = ( - selectAll - ? await this.db - .select() - .from(agentTimelineRows) - .where( - and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)), - ) - .orderBy(asc(agentTimelineRows.seq)) - : await this.db - .select() - .from(agentTimelineRows) - .where( - and(eq(agentTimelineRows.agentId, agentId), gt(agentTimelineRows.seq, baseSeq)), - ) - .orderBy(asc(agentTimelineRows.seq)) - .limit(limit) - ).map(toTimelineRow); - - if (rows.length === 0) { - return { - direction, - window, - hasOlder: baseSeq >= window.minSeq, - hasNewer: false, - rows, - }; - } - - const lastSelected = rows[rows.length - 1]!; - return { - direction, - window, - hasOlder: rows[0]!.seq > window.minSeq, - hasNewer: lastSelected.seq < window.maxSeq, - rows, - }; - } - - const beforeSeq = options?.cursor?.seq ?? window.nextSeq; - const rows = ( - selectAll - ? await this.db - .select() - .from(agentTimelineRows) - .where( - and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)), - ) - .orderBy(asc(agentTimelineRows.seq)) - : ( - await this.db - .select() - .from(agentTimelineRows) - .where( - and(eq(agentTimelineRows.agentId, agentId), lt(agentTimelineRows.seq, beforeSeq)), - ) - .orderBy(desc(agentTimelineRows.seq)) - .limit(limit) - ).reverse() - ).map(toTimelineRow); - - return { - direction, - window, - hasOlder: rows.length > 0 && rows[0]!.seq > window.minSeq, - hasNewer: beforeSeq <= window.maxSeq, - rows, - }; - } - - async getLatestCommittedSeq(agentId: string): Promise { - return this.getMaxSeq(agentId); - } - - async getCommittedRows(agentId: string): Promise { - const rows = await this.db - .select() - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)) - .orderBy(asc(agentTimelineRows.seq)); - return rows.map(toTimelineRow); - } - - async getLastItem(agentId: string): Promise { - const [row] = await this.db - .select({ item: agentTimelineRows.item }) - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)) - .orderBy(desc(agentTimelineRows.seq)) - .limit(1); - return row?.item ?? null; - } - - async getLastAssistantMessage(agentId: string): Promise { - const rows = await this.db - .select({ - seq: agentTimelineRows.seq, - item: agentTimelineRows.item, - }) - .from(agentTimelineRows) - .where( - and( - eq(agentTimelineRows.agentId, agentId), - eq(agentTimelineRows.itemKind, "assistant_message"), - ), - ) - .orderBy(desc(agentTimelineRows.seq)); - - if (rows.length === 0) { - return null; - } - - const chunks: string[] = []; - let previousSeq: number | null = null; - for (const row of rows) { - if (previousSeq !== null && row.seq !== previousSeq - 1) { - break; - } - if (row.item.type !== "assistant_message") { - break; - } - chunks.push(row.item.text); - previousSeq = row.seq; - } - - return chunks.length > 0 ? chunks.reverse().join("") : null; - } - - async hasCommittedUserMessage( - agentId: string, - options: { messageId: string; text: string }, - ): Promise { - const messageId = normalizeTimelineMessageId(options.messageId); - if (!messageId) { - return false; - } - - const [row] = await this.db - .select({ seq: agentTimelineRows.seq }) - .from(agentTimelineRows) - .where( - and( - eq(agentTimelineRows.agentId, agentId), - eq(agentTimelineRows.itemKind, "user_message"), - sql`json_extract(${agentTimelineRows.item}, '$.messageId') = ${messageId}`, - sql`json_extract(${agentTimelineRows.item}, '$.text') = ${options.text}`, - ), - ) - .limit(1); - - return row !== undefined; - } - - async deleteAgent(agentId: string): Promise { - await this.db.delete(agentTimelineRows).where(eq(agentTimelineRows.agentId, agentId)); - } - - async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise { - if (rows.length === 0) { - return; - } - await this.db.insert(agentTimelineRows).values(rows.map((row) => toInsertValues(agentId, row))); - } - - private async getMaxSeq(agentId: string): Promise { - const [row] = await this.db - .select({ - maxSeq: sql`coalesce(max(${agentTimelineRows.seq}), 0)`, - }) - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)); - return Number(row?.maxSeq ?? 0); - } - - private async getWindow(agentId: string): Promise { - const [row] = await this.db - .select({ - minSeq: sql`coalesce(min(${agentTimelineRows.seq}), 0)`, - maxSeq: sql`coalesce(max(${agentTimelineRows.seq}), 0)`, - }) - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, agentId)); - const minSeq = Number(row?.minSeq ?? 0); - const maxSeq = Number(row?.maxSeq ?? 0); - return { - minSeq, - maxSeq, - nextSeq: maxSeq + 1, - }; - } -} diff --git a/packages/server/src/server/db/db-project-registry.ts b/packages/server/src/server/db/db-project-registry.ts deleted file mode 100644 index c7fad1b5d..000000000 --- a/packages/server/src/server/db/db-project-registry.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { eq } from "drizzle-orm"; - -import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js"; -import { createPersistedProjectRecord } from "../workspace-registry.js"; -import { projects } from "./schema.js"; -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; - -function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord { - return createPersistedProjectRecord({ - ...row, - kind: row.kind as PersistedProjectRecord["kind"], - }); -} - -export class DbProjectRegistry implements ProjectRegistry { - private readonly db: PaseoDatabaseHandle["db"]; - - constructor(db: PaseoDatabaseHandle["db"]) { - this.db = db; - } - - async initialize(): Promise { - return Promise.resolve(); - } - - async existsOnDisk(): Promise { - return true; - } - - async list(): Promise { - const rows = await this.db.select().from(projects); - return rows.map(toPersistedProjectRecord); - } - - async get(id: number): Promise { - const rows = await this.db.select().from(projects).where(eq(projects.id, id)).limit(1); - const row = rows[0]; - return row ? toPersistedProjectRecord(row) : null; - } - - async insert(record: Omit): Promise { - const [row] = await this.db - .insert(projects) - .values(record) - .onConflictDoUpdate({ - target: projects.directory, - set: { - kind: record.kind, - displayName: record.displayName, - gitRemote: record.gitRemote, - updatedAt: record.updatedAt, - archivedAt: record.archivedAt, - }, - }) - .returning({ id: projects.id }); - return row!.id; - } - - async upsert(record: PersistedProjectRecord): Promise { - const nextRecord = createPersistedProjectRecord(record); - await this.db - .insert(projects) - .values(nextRecord) - .onConflictDoUpdate({ - target: projects.directory, - set: { - kind: nextRecord.kind, - displayName: nextRecord.displayName, - gitRemote: nextRecord.gitRemote, - updatedAt: nextRecord.updatedAt, - archivedAt: nextRecord.archivedAt, - }, - }); - } - - async archive(id: number, archivedAt: string): Promise { - await this.db - .update(projects) - .set({ - updatedAt: archivedAt, - archivedAt, - }) - .where(eq(projects.id, id)); - } - - async remove(id: number): Promise { - await this.db.delete(projects).where(eq(projects.id, id)); - } -} diff --git a/packages/server/src/server/db/db-workspace-registry.test.ts b/packages/server/src/server/db/db-workspace-registry.test.ts deleted file mode 100644 index 499c99ba2..000000000 --- a/packages/server/src/server/db/db-workspace-registry.test.ts +++ /dev/null @@ -1,204 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; -import { - createPersistedProjectRecord, - createPersistedWorkspaceRecord, -} from "../workspace-registry.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { DbProjectRegistry } from "./db-project-registry.js"; -import { DbWorkspaceRegistry } from "./db-workspace-registry.js"; - -function createProjectRecord(input: Partial = {}): PersistedProjectRecord { - return createPersistedProjectRecord({ - id: 1, - directory: "/tmp/repo", - kind: "git", - displayName: "acme/repo", - gitRemote: "git@github.com:acme/repo.git", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - ...input, - }); -} - -function createWorkspaceRecord( - input: Partial = {}, -): PersistedWorkspaceRecord { - return createPersistedWorkspaceRecord({ - id: 1, - projectId: 1, - directory: "/tmp/repo", - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - ...input, - }); -} - -describe("DB-backed workspace registries", () => { - let tmpDir: string; - let dataDir: string; - let database: PaseoDatabaseHandle; - let projectRegistry: DbProjectRegistry; - let workspaceRegistry: DbWorkspaceRegistry; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-")); - dataDir = path.join(tmpDir, "db"); - database = await openPaseoDatabase(dataDir); - projectRegistry = new DbProjectRegistry(database.db); - workspaceRegistry = new DbWorkspaceRegistry(database.db); - }); - - afterEach(async () => { - await database.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("project registry matches the file-backed behavioral contract", async () => { - await projectRegistry.initialize(); - expect(await projectRegistry.existsOnDisk()).toBe(true); - expect(await projectRegistry.get(999)).toBeNull(); - expect(await projectRegistry.list()).toEqual([]); - - const projectId = await projectRegistry.insert({ - directory: "/tmp/repo", - kind: "git", - displayName: "acme/repo", - gitRemote: "git@github.com:acme/repo.git", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - await projectRegistry.upsert( - createProjectRecord({ - id: projectId, - updatedAt: "2026-03-02T00:00:00.000Z", - }), - ); - await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z"); - await projectRegistry.archive(999, "2026-03-04T00:00:00.000Z"); - - expect(await projectRegistry.get(projectId)).toEqual( - createProjectRecord({ - id: projectId, - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: "2026-03-03T00:00:00.000Z", - }), - ); - expect(await projectRegistry.list()).toEqual([ - createProjectRecord({ - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: "2026-03-03T00:00:00.000Z", - }), - ]); - - await projectRegistry.remove(999); - await projectRegistry.remove(projectId); - - expect(await projectRegistry.get(projectId)).toBeNull(); - expect(await projectRegistry.list()).toEqual([]); - }); - - test("workspace registry matches the file-backed behavioral contract", async () => { - await workspaceRegistry.initialize(); - expect(await workspaceRegistry.existsOnDisk()).toBe(true); - expect(await workspaceRegistry.get(999)).toBeNull(); - expect(await workspaceRegistry.list()).toEqual([]); - - const projectId = await projectRegistry.insert({ - directory: "/tmp/repo", - kind: "git", - displayName: "acme/repo", - gitRemote: "git@github.com:acme/repo.git", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - const workspaceId = await workspaceRegistry.insert({ - projectId, - directory: "/tmp/repo", - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - await workspaceRegistry.upsert( - createWorkspaceRecord({ - id: workspaceId, - projectId, - displayName: "feature/workspace", - updatedAt: "2026-03-02T00:00:00.000Z", - }), - ); - await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z"); - await workspaceRegistry.archive(999, "2026-03-04T00:00:00.000Z"); - - expect(await workspaceRegistry.get(workspaceId)).toEqual( - createWorkspaceRecord({ - id: workspaceId, - projectId, - displayName: "feature/workspace", - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: "2026-03-03T00:00:00.000Z", - }), - ); - expect(await workspaceRegistry.list()).toEqual([ - createWorkspaceRecord({ - displayName: "feature/workspace", - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: "2026-03-03T00:00:00.000Z", - }), - ]); - - await workspaceRegistry.remove(999); - await workspaceRegistry.remove(workspaceId); - - expect(await workspaceRegistry.get(workspaceId)).toBeNull(); - expect(await workspaceRegistry.list()).toEqual([]); - }); - - test("rejects workspace upserts for non-existent projects", async () => { - await expect( - workspaceRegistry.upsert( - createWorkspaceRecord({ - projectId: 999, - }), - ), - ).rejects.toThrow(); - }); - - test("cascades workspace removal when removing a linked project", async () => { - const projectId = await projectRegistry.insert({ - directory: "/tmp/repo", - kind: "git", - displayName: "acme/repo", - gitRemote: "git@github.com:acme/repo.git", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - const workspaceId = await workspaceRegistry.insert({ - projectId, - directory: "/tmp/repo", - kind: "checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }); - - await projectRegistry.remove(projectId); - expect(await projectRegistry.get(projectId)).toBeNull(); - expect(await workspaceRegistry.get(workspaceId)).toBeNull(); - }); -}); diff --git a/packages/server/src/server/db/db-workspace-registry.ts b/packages/server/src/server/db/db-workspace-registry.ts deleted file mode 100644 index dda4008b7..000000000 --- a/packages/server/src/server/db/db-workspace-registry.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { eq } from "drizzle-orm"; - -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; -import { workspaces } from "./schema.js"; -import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js"; -import { createPersistedWorkspaceRecord } from "../workspace-registry.js"; - -function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord { - return createPersistedWorkspaceRecord({ - ...row, - kind: row.kind as PersistedWorkspaceRecord["kind"], - }); -} - -export class DbWorkspaceRegistry implements WorkspaceRegistry { - private readonly db: PaseoDatabaseHandle["db"]; - - constructor(db: PaseoDatabaseHandle["db"]) { - this.db = db; - } - - async initialize(): Promise { - return Promise.resolve(); - } - - async existsOnDisk(): Promise { - return true; - } - - async list(): Promise { - const rows = await this.db.select().from(workspaces); - return rows.map(toPersistedWorkspaceRecord); - } - - async get(id: number): Promise { - const rows = await this.db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1); - const row = rows[0]; - return row ? toPersistedWorkspaceRecord(row) : null; - } - - async insert(record: Omit): Promise { - const [row] = await this.db - .insert(workspaces) - .values(record) - .onConflictDoUpdate({ - target: workspaces.directory, - set: { - projectId: record.projectId, - kind: record.kind, - displayName: record.displayName, - updatedAt: record.updatedAt, - archivedAt: record.archivedAt, - }, - }) - .returning({ id: workspaces.id }); - return row!.id; - } - - async upsert(record: PersistedWorkspaceRecord): Promise { - const nextRecord = createPersistedWorkspaceRecord(record); - await this.db - .insert(workspaces) - .values(nextRecord) - .onConflictDoUpdate({ - target: workspaces.directory, - set: { - projectId: nextRecord.projectId, - kind: nextRecord.kind, - displayName: nextRecord.displayName, - updatedAt: nextRecord.updatedAt, - archivedAt: nextRecord.archivedAt, - }, - }); - } - - async archive(workspaceId: number, archivedAt: string): Promise { - await this.db - .update(workspaces) - .set({ - updatedAt: archivedAt, - archivedAt, - }) - .where(eq(workspaces.id, workspaceId)); - } - - async remove(workspaceId: number): Promise { - await this.db.delete(workspaces).where(eq(workspaces.id, workspaceId)); - } -} diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts deleted file mode 100644 index b0382e926..000000000 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.test.ts +++ /dev/null @@ -1,312 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; - -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js"; -import { agentSnapshots, projects, workspaces } from "./schema.js"; - -describe("importLegacyAgentSnapshots", () => { - let tmpDir: string; - let paseoHome: string; - let dbDir: string; - let database: PaseoDatabaseHandle; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-agent-import-")); - paseoHome = path.join(tmpDir, ".paseo"); - dbDir = path.join(paseoHome, "db"); - mkdirSync(paseoHome, { recursive: true }); - database = await openPaseoDatabase(dbDir); - }); - - afterEach(async () => { - await database.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - async function seedWorkspace(directory: string): Promise { - const [project] = await database.db - .insert(projects) - .values({ - directory, - displayName: path.basename(directory), - kind: "directory", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning({ id: projects.id }); - const [workspace] = await database.db - .insert(workspaces) - .values({ - projectId: project!.id, - directory, - displayName: path.basename(directory), - kind: "checkout", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning({ id: workspaces.id }); - return workspace!.id; - } - - test("imports agent JSON files when the DB is empty", async () => { - await seedWorkspace("/tmp/project"); - writeLegacyAgentJson({ - paseoHome, - relativePath: "agents/agent-1.json", - payload: createLegacyAgentJson({ - requiresAttention: undefined, - internal: undefined, - }), - }); - - const result = await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "imported", - importedAgents: 1, - }); - expect(await database.db.select().from(agentSnapshots)).toEqual([ - expect.objectContaining({ - agentId: "agent-1", - cwd: "/tmp/project", - requiresAttention: false, - internal: false, - }), - ]); - }); - - test("skips import when the DB already has agent data", async () => { - const workspaceId = await seedWorkspace("/tmp/existing-project"); - await database.db.insert(agentSnapshots).values({ - agentId: "existing-agent", - provider: "codex", - workspaceId, - cwd: "/tmp/existing-project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - lastActivityAt: "2026-03-01T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: "plan", - config: null, - runtimeInfo: { provider: "codex", sessionId: "session-existing" }, - persistence: null, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: null, - }); - writeLegacyAgentJson({ - paseoHome, - relativePath: "agents/legacy-agent.json", - payload: createLegacyAgentJson({ id: "legacy-agent" }), - }); - - const result = await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "skipped", - reason: "database-not-empty", - }); - expect(await database.db.select().from(agentSnapshots)).toHaveLength(1); - }); - - test("imports agent JSON files from nested project directories", async () => { - await seedWorkspace("/tmp/root-project"); - await seedWorkspace("/tmp/nested-project"); - writeLegacyAgentJson({ - paseoHome, - relativePath: "agents/agent-root.json", - payload: createLegacyAgentJson({ id: "agent-root", cwd: "/tmp/root-project" }), - }); - writeLegacyAgentJson({ - paseoHome, - relativePath: "agents/tmp-nested-project/agent-nested.json", - payload: createLegacyAgentJson({ id: "agent-nested", cwd: "/tmp/nested-project" }), - }); - - const result = await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "imported", - importedAgents: 2, - }); - expect( - (await database.db.select().from(agentSnapshots)).map((row) => row.agentId).sort(), - ).toEqual(["agent-nested", "agent-root"]); - }); - - test("batches large legacy agent imports so SQLite variable limits do not abort bootstrap", async () => { - await seedWorkspace("/tmp/large-project"); - - for (let index = 0; index < 150; index += 1) { - writeLegacyAgentJson({ - paseoHome, - relativePath: `agents/large-project/agent-${index}.json`, - payload: createLegacyAgentJson({ - id: `agent-${index}`, - cwd: "/tmp/large-project", - runtimeInfo: { - provider: "codex", - sessionId: `session-${index}`, - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - }), - }); - } - - const result = await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "imported", - importedAgents: 150, - }); - const rows = await database.db.select().from(agentSnapshots); - expect(rows).toHaveLength(150); - expect(rows.map((row) => row.agentId)).toContain("agent-149"); - }); - - test("creates backup of agent directory before import", async () => { - await seedWorkspace("/tmp/project"); - writeLegacyAgentJson({ - paseoHome, - relativePath: "agents/project-a/agent-1.json", - payload: createLegacyAgentJson(), - }); - - await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - const backupPath = path.join( - paseoHome, - "backup", - "pre-migration", - "agents", - "project-a", - "agent-1.json", - ); - expect(existsSync(backupPath)).toBe(true); - expect(JSON.parse(readFileSync(backupPath, "utf8"))).toMatchObject({ - id: "agent-1", - cwd: "/tmp/project", - }); - }); - - test("logs batch progress for large imports", async () => { - await seedWorkspace("/tmp/large-project"); - const logger = createTestLogger(); - const infoSpy = vi.spyOn(logger, "info"); - - for (let index = 0; index < 150; index += 1) { - writeLegacyAgentJson({ - paseoHome, - relativePath: `agents/large-project/agent-${index}.json`, - payload: createLegacyAgentJson({ - id: `agent-${index}`, - cwd: "/tmp/large-project", - runtimeInfo: { - provider: "codex", - sessionId: `session-${index}`, - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - }), - }); - } - - await importLegacyAgentSnapshots({ - db: database.db, - paseoHome, - logger, - }); - - const batchLogs = infoSpy.mock.calls.filter( - ([context, message]) => - message === "Importing agent snapshot batch" && typeof context === "object", - ); - expect(batchLogs.length).toBeGreaterThan(1); - expect(batchLogs[0]?.[0]).toMatchObject({ - batch: 1, - totalBatches: batchLogs.length, - }); - expect(batchLogs.at(-1)?.[0]).toMatchObject({ - batch: batchLogs.length, - totalBatches: batchLogs.length, - rowsProcessed: 150, - }); - }); -}); - -function createLegacyAgentJson(overrides: Record = {}): Record { - return { - id: "agent-1", - provider: "codex", - cwd: "/tmp/project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - lastActivityAt: "2026-03-02T00:00:00.000Z", - lastUserMessageAt: null, - title: null, - labels: {}, - lastStatus: "idle", - lastModeId: "plan", - config: { - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - runtimeInfo: { - provider: "codex", - sessionId: "session-123", - model: "gpt-5.1-codex-mini", - modeId: "plan", - }, - persistence: null, - attentionReason: null, - attentionTimestamp: null, - archivedAt: null, - ...overrides, - }; -} - -function writeLegacyAgentJson(input: { - paseoHome: string; - relativePath: string; - payload: Record; -}): void { - const absolutePath = path.join(input.paseoHome, input.relativePath); - mkdirSync(path.dirname(absolutePath), { recursive: true }); - writeFileSync(absolutePath, JSON.stringify(input.payload, null, 2), { - encoding: "utf8", - flag: "w", - }); -} diff --git a/packages/server/src/server/db/legacy-agent-snapshot-import.ts b/packages/server/src/server/db/legacy-agent-snapshot-import.ts deleted file mode 100644 index 73e266002..000000000 --- a/packages/server/src/server/db/legacy-agent-snapshot-import.ts +++ /dev/null @@ -1,311 +0,0 @@ -import path from "node:path"; -import { execSync } from "node:child_process"; -import { promises as fs } from "node:fs"; - -import { count } from "drizzle-orm"; -import type { Logger } from "pino"; - -import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js"; -import { detectWorkspaceGitMetadata } from "../workspace-git-metadata.js"; -import { READ_ONLY_GIT_ENV } from "../checkout-git-utils.js"; -import { normalizeWorkspaceId } from "../workspace-registry-model.js"; -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; -import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js"; -import { agentSnapshots, projects, workspaces } from "./schema.js"; - -const SQLITE_MAX_VARIABLES_PER_STATEMENT = 999; -const AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW = Object.keys(agentSnapshots).length; -const MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT = Math.max( - 1, - Math.floor(SQLITE_MAX_VARIABLES_PER_STATEMENT / AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW), -); - -export type LegacyAgentSnapshotImportResult = - | { - status: "imported"; - importedAgents: number; - } - | { - status: "skipped"; - reason: "database-not-empty" | "no-legacy-files"; - }; - -export async function importLegacyAgentSnapshots(options: { - db: PaseoDatabaseHandle["db"]; - paseoHome: string; - logger: Logger; -}): Promise { - if (await hasAnyAgentSnapshotRows(options.db)) { - options.logger.info("Skipping legacy agent snapshot import because the DB is not empty"); - return { - status: "skipped", - reason: "database-not-empty", - }; - } - - const agentsDir = path.join(options.paseoHome, "agents"); - if (!(await pathExists(agentsDir))) { - options.logger.info("Skipping legacy agent snapshot import because no legacy files exist"); - return { - status: "skipped", - reason: "no-legacy-files", - }; - } - - await backupLegacyAgentDirectory({ - sourceDir: agentsDir, - paseoHome: options.paseoHome, - logger: options.logger, - }); - - const { records, skippedCount } = await readLegacyAgentRecords(agentsDir, options.logger); - if (skippedCount > 0) { - options.logger.warn({ skippedCount }, "Skipped invalid agent JSON files during migration"); - } - if (records.length === 0) { - options.logger.info("Skipping legacy agent snapshot import because no legacy files exist"); - return { - status: "skipped", - reason: "no-legacy-files", - }; - } - - options.db.transaction((tx) => { - const workspaceRows = tx - .select({ id: workspaces.id, directory: workspaces.directory }) - .from(workspaces) - .all(); - const workspaceIdsByDirectory = new Map( - workspaceRows.map((row) => [row.directory, row.id] as const), - ); - const projectRows = tx - .select({ id: projects.id, directory: projects.directory, gitRemote: projects.gitRemote }) - .from(projects) - .all(); - const projectIdsByDirectory = new Map( - projectRows.map((row) => [row.directory, row.id] as const), - ); - const projectIdsByRemote = new Map( - projectRows - .filter((row): row is typeof row & { gitRemote: string } => row.gitRemote !== null) - .map((row) => [row.gitRemote, row.id] as const), - ); - for (const record of records) { - const normalizedDirectory = normalizeWorkspaceId(record.cwd); - if (workspaceIdsByDirectory.has(normalizedDirectory)) { - continue; - } - - const timestamp = record.updatedAt ?? record.createdAt; - const gitInfo = detectGitInfoForCwd(record.cwd); - const resolvedDirectory = gitInfo?.toplevel - ? normalizeWorkspaceId(gitInfo.toplevel) - : normalizedDirectory; - const projectDisplayName = - gitInfo?.metadata.projectDisplayName ?? - resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? - resolvedDirectory; - const projectKind = gitInfo?.metadata.projectKind ?? "directory"; - const gitRemote = gitInfo?.metadata.gitRemote ?? null; - const workspaceKind = gitInfo?.metadata.isWorktree ? "worktree" : "checkout"; - const workspaceDisplayName = - gitInfo?.metadata.workspaceDisplayName ?? - normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? - normalizedDirectory; - - let projectId = - projectIdsByDirectory.get(resolvedDirectory) ?? - (gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined); - if (projectId === undefined) { - const projectRow = tx - .insert(projects) - .values({ - directory: resolvedDirectory, - displayName: projectDisplayName, - kind: projectKind, - gitRemote, - createdAt: record.createdAt, - updatedAt: timestamp, - archivedAt: null, - }) - .returning({ id: projects.id }) - .get(); - projectId = projectRow!.id; - projectIdsByDirectory.set(resolvedDirectory, projectId); - if (gitRemote !== null) { - projectIdsByRemote.set(gitRemote, projectId); - } - } - - const workspaceRow = tx - .insert(workspaces) - .values({ - projectId, - directory: normalizedDirectory, - displayName: workspaceDisplayName, - kind: workspaceKind, - createdAt: record.createdAt, - updatedAt: timestamp, - archivedAt: null, - }) - .returning({ id: workspaces.id }) - .get(); - workspaceIdsByDirectory.set(normalizedDirectory, workspaceRow!.id); - } - const rows = records.flatMap((record) => { - const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd)); - if (workspaceId === undefined) { - return []; - } - const clampedRecord = - record.lastStatus === "running" || record.lastStatus === "initializing" - ? { ...record, lastStatus: "closed" as const } - : record; - return [toAgentSnapshotRowValues({ record: clampedRecord, workspaceId })]; - }); - const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT); - for ( - let startIndex = 0; - startIndex < rows.length; - startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT - ) { - const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT); - const batchNum = Math.floor(startIndex / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) + 1; - const rowsProcessed = startIndex + batch.length; - options.logger.info( - { batch: batchNum, totalBatches, rowsProcessed }, - "Importing agent snapshot batch", - ); - tx.insert(agentSnapshots).values(batch).run(); - } - }); - - options.logger.info( - { importedAgents: records.length }, - "Imported legacy agent snapshots into the database", - ); - - return { - status: "imported", - importedAgents: records.length, - }; -} - -async function readLegacyAgentRecords( - baseDir: string, - logger: Logger, -): Promise<{ - records: StoredAgentRecord[]; - skippedCount: number; -}> { - let entries: Array = []; - try { - entries = await fs.readdir(baseDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { records: [], skippedCount: 0 }; - } - throw error; - } - - const recordsById = new Map(); - let skippedCount = 0; - for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(".json")) { - const record = await readRecordFile(path.join(baseDir, entry.name), logger); - if (record) { - recordsById.set(record.id, record); - } else { - skippedCount += 1; - } - continue; - } - - if (!entry.isDirectory()) { - continue; - } - - let childEntries: Array = []; - try { - childEntries = await fs.readdir(path.join(baseDir, entry.name), { withFileTypes: true }); - } catch { - continue; - } - - for (const childEntry of childEntries) { - if (!childEntry.isFile() || !childEntry.name.endsWith(".json")) { - continue; - } - const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger); - if (record) { - recordsById.set(record.id, record); - } else { - skippedCount += 1; - } - } - } - - return { - records: Array.from(recordsById.values()), - skippedCount, - }; -} - -async function readRecordFile(filePath: string, logger: Logger): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - return parseStoredAgentRecord(JSON.parse(raw)); - } catch (error) { - logger.error({ err: error, filePath }, "Skipping invalid legacy agent snapshot"); - return null; - } -} - -async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise { - const rows = await db.select({ count: count() }).from(agentSnapshots); - return (rows[0]?.count ?? 0) > 0; -} - -async function backupLegacyAgentDirectory(options: { - sourceDir: string; - paseoHome: string; - logger: Logger; -}): Promise { - const backupPath = path.join(options.paseoHome, "backup", "pre-migration", "agents"); - await fs.mkdir(path.dirname(backupPath), { recursive: true }); - await fs.cp(options.sourceDir, backupPath, { recursive: true }); - options.logger.info({ backupPath }, "Backed up legacy agent snapshots before migration"); -} - -async function pathExists(targetPath: string): Promise { - try { - await fs.access(targetPath); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return false; - } - throw error; - } -} - -function detectGitInfoForCwd( - cwd: string, -): { toplevel: string; metadata: ReturnType } | null { - try { - const toplevel = execSync("git rev-parse --show-toplevel", { - cwd, - env: READ_ONLY_GIT_ENV, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - if (!toplevel) { - return null; - } - const directoryName = toplevel.split(/[\\/]/).filter(Boolean).at(-1) ?? toplevel; - const metadata = detectWorkspaceGitMetadata(cwd, directoryName); - return { toplevel, metadata }; - } catch { - return null; - } -} diff --git a/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts b/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts deleted file mode 100644 index 1e14030d8..000000000 --- a/packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Adhoc test: imports real legacy data from ~/.paseo into a fresh SQLite DB - * and asserts that projects/workspaces are properly grouped. - * - * Run with: npx vitest run packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts - */ -import os from "node:os"; -import path from "node:path"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js"; -import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js"; -import { projects, workspaces, agentSnapshots } from "./schema.js"; -import { eq } from "drizzle-orm"; - -const REAL_PASEO_HOME = path.join(os.homedir(), ".paseo"); - -describe("legacy import from real ~/.paseo data", () => { - let tmpDir: string; - let dbDir: string; - let database: PaseoDatabaseHandle; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-real-import-")); - dbDir = path.join(tmpDir, "db"); - mkdirSync(dbDir, { recursive: true }); - database = await openPaseoDatabase(dbDir); - }); - - afterEach(async () => { - await database?.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("imports real data and groups projects correctly", async () => { - const logger = createTestLogger(); - - // Phase 1: Import legacy project/workspace JSON (has proper grouping) - const pwResult = await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome: REAL_PASEO_HOME, - logger, - }); - console.log("Project/workspace import result:", pwResult); - - // Phase 2: Import legacy agent snapshots - const agentResult = await importLegacyAgentSnapshots({ - db: database.db, - paseoHome: REAL_PASEO_HOME, - logger, - }); - console.log("Agent snapshot import result:", agentResult); - - // --- Assertions --- - - const allProjects = await database.db.select().from(projects); - const allWorkspaces = await database.db.select().from(workspaces); - const allAgents = await database.db.select().from(agentSnapshots); - - console.log(`Total projects: ${allProjects.length}`); - console.log(`Total workspaces: ${allWorkspaces.length}`); - console.log(`Total agents: ${allAgents.length}`); - - // 1. There should be fewer projects than workspaces (workspaces group under projects) - expect(allProjects.length).toBeLessThan(allWorkspaces.length); - - // 2. There should be exactly ONE project per unique git remote - const projectsByRemote = new Map(); - for (const project of allProjects) { - if (project.gitRemote) { - const existing = projectsByRemote.get(project.gitRemote) ?? []; - existing.push(project); - projectsByRemote.set(project.gitRemote, existing); - } - } - - const duplicateRemotes: string[] = []; - for (const [remote, projectList] of projectsByRemote) { - if (projectList.length > 1) { - duplicateRemotes.push(remote); - console.log(`DUPLICATE: ${remote} has ${projectList.length} projects:`); - for (const p of projectList) { - console.log(` id=${p.id} directory=${p.directory}`); - } - } - } - expect(duplicateRemotes).toEqual([]); - - // 3. Specifically: getpaseo/paseo should be ONE project - const paseoProjects = allProjects.filter( - (p) => p.gitRemote === "git@github.com:getpaseo/paseo.git", - ); - expect(paseoProjects).toHaveLength(1); - const paseoProject = paseoProjects[0]!; - - // 4. All paseo workspaces (worktrees + main checkout + subdirs) should be under that one project - const paseoWorkspaces = allWorkspaces.filter((w) => w.projectId === paseoProject.id); - console.log(`Paseo project id=${paseoProject.id}, directory=${paseoProject.directory}`); - console.log(`Paseo workspaces: ${paseoWorkspaces.length}`); - - // The old data had ~51 paseo workspaces - expect(paseoWorkspaces.length).toBeGreaterThanOrEqual(10); - - // 5. Subdirectory workspaces (packages/server, packages/app) should be under the same project - const subdirWorkspaces = paseoWorkspaces.filter((w) => w.directory.includes("/packages/")); - console.log(`Paseo subdirectory workspaces: ${subdirWorkspaces.length}`); - for (const w of subdirWorkspaces) { - console.log(` ${w.directory} (projectId=${w.projectId})`); - expect(w.projectId).toBe(paseoProject.id); - } - - // 6. Worktree workspaces should be under the same project - const worktreeWorkspaces = paseoWorkspaces.filter((w) => - w.directory.includes("/.paseo/worktrees/"), - ); - console.log(`Paseo worktree workspaces: ${worktreeWorkspaces.length}`); - expect(worktreeWorkspaces.length).toBeGreaterThan(0); - - // 7. No git project should be a subdirectory of another project with the SAME git remote - // (e.g., /dev/paseo/packages/server should not be its own project if /dev/paseo exists) - const activeGitProjects = allProjects.filter((p) => !p.archivedAt && p.gitRemote); - const subdirProjects: string[] = []; - for (const project of activeGitProjects) { - for (const other of activeGitProjects) { - if ( - project.id !== other.id && - project.gitRemote === other.gitRemote && - project.directory.startsWith(other.directory + "/") - ) { - subdirProjects.push( - `${project.directory} (id=${project.id}) is under ${other.directory} (id=${other.id}), both remote=${project.gitRemote}`, - ); - } - } - } - if (subdirProjects.length > 0) { - console.log("Subdirectory git projects with same remote (should be empty):"); - for (const s of subdirProjects) { - console.log(` ${s}`); - } - } - expect(subdirProjects).toEqual([]); - - // 8. All agent snapshots should reference valid workspaces - for (const agent of allAgents) { - const workspace = allWorkspaces.find((w) => w.id === agent.workspaceId); - expect(workspace).toBeDefined(); - } - }); -}); diff --git a/packages/server/src/server/db/legacy-project-workspace-import.test.ts b/packages/server/src/server/db/legacy-project-workspace-import.test.ts deleted file mode 100644 index 6d6a70692..000000000 --- a/packages/server/src/server/db/legacy-project-workspace-import.test.ts +++ /dev/null @@ -1,324 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; - -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import { createTestLogger } from "../../test-utils/test-logger.js"; -import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js"; -import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js"; -import { projects, workspaces } from "./schema.js"; - -describe("importLegacyProjectWorkspaceJson", () => { - let tmpDir: string; - let paseoHome: string; - let dbDir: string; - let database: PaseoDatabaseHandle; - - beforeEach(async () => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-")); - paseoHome = path.join(tmpDir, ".paseo"); - dbDir = path.join(paseoHome, "db"); - mkdirSync(paseoHome, { recursive: true }); - database = await openPaseoDatabase(dbDir); - }); - - afterEach(async () => { - await database?.close(); - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("imports legacy projects and workspaces once when the DB is empty", async () => { - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "project-1", - rootPath: "/tmp/project-1", - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: "/tmp/project-1", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - const result = await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "imported", - importedProjects: 1, - importedWorkspaces: 1, - }); - const projectRows = await database.db.select().from(projects); - expect(projectRows).toEqual([ - expect.objectContaining({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - }), - ]); - expect(typeof projectRows[0]!.id).toBe("number"); - - const workspaceRows = await database.db.select().from(workspaces); - expect(workspaceRows).toEqual([ - expect.objectContaining({ - projectId: projectRows[0]!.id, - directory: "/tmp/project-1", - kind: "checkout", - displayName: "main", - }), - ]); - }); - - test("skips import when the DB already has project or workspace data", async () => { - // Seed with a project in the new schema format - const [inserted] = await database.db - .insert(projects) - .values({ - directory: "/tmp/existing-project", - kind: "git", - displayName: "Existing Project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }) - .returning({ id: projects.id }); - - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "legacy-project", - rootPath: "/tmp/legacy-project", - kind: "git", - displayName: "Legacy Project", - createdAt: "2026-03-02T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [], - }); - - const result = await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "skipped", - reason: "database-not-empty", - }); - // Only the existing project should be in DB - const allProjects = await database.db.select().from(projects); - expect(allProjects).toHaveLength(1); - expect(allProjects[0]!.id).toBe(inserted!.id); - }); - - test("rolls back the whole import when workspace insertion fails", async () => { - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "project-1", - rootPath: "/tmp/project-1", - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [ - { - workspaceId: "workspace-1", - projectId: "missing-project", - cwd: "/tmp/project-1", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - await expect( - importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }), - ).rejects.toThrow(); - - expect(await database.db.select().from(projects)).toEqual([]); - expect(await database.db.select().from(workspaces)).toEqual([]); - }); - - test("deduplicates projects with the same rootPath", async () => { - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "project-1", - rootPath: "/tmp/project-1", - kind: "git", - displayName: "First Project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-01T00:00:00.000Z", - archivedAt: null, - }, - { - projectId: "project-2", - rootPath: "/tmp/project-1", - kind: "git", - displayName: "Replacement Project", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [ - { - workspaceId: "workspace-1", - projectId: "project-2", - cwd: "/tmp/project-1", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-03T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - const result = await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - expect(result).toEqual({ - status: "imported", - importedProjects: 1, - importedWorkspaces: 1, - }); - const projectRows = await database.db.select().from(projects); - expect(projectRows).toHaveLength(1); - expect(projectRows[0]).toEqual( - expect.objectContaining({ - directory: "/tmp/project-1", - displayName: "First Project", - }), - ); - }); - - test("creates backup of JSON files before import", async () => { - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "project-1", - rootPath: "/tmp/project-1", - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [ - { - workspaceId: "workspace-1", - projectId: "project-1", - cwd: "/tmp/project-1", - kind: "local_checkout", - displayName: "main", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - }); - - await importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }); - - const backupDir = path.join(paseoHome, "backup", "pre-migration"); - const projectsBackupPath = path.join(backupDir, "projects.json"); - const workspacesBackupPath = path.join(backupDir, "workspaces.json"); - expect(existsSync(projectsBackupPath)).toBe(true); - expect(existsSync(workspacesBackupPath)).toBe(true); - expect(JSON.parse(readFileSync(projectsBackupPath, "utf8"))).toHaveLength(1); - expect(JSON.parse(readFileSync(workspacesBackupPath, "utf8"))).toHaveLength(1); - }); - - test("produces clear error message for corrupt project JSON", async () => { - writeLegacyJson({ - paseoHome, - projectsJson: [ - { - projectId: "project-1", - rootPath: 123, - kind: "git", - displayName: "Project One", - createdAt: "2026-03-01T00:00:00.000Z", - updatedAt: "2026-03-02T00:00:00.000Z", - archivedAt: null, - }, - ], - workspacesJson: [], - }); - - await expect( - importLegacyProjectWorkspaceJson({ - db: database.db, - paseoHome, - logger: createTestLogger(), - }), - ).rejects.toThrow( - `Failed to parse ${path.join(paseoHome, "projects", "projects.json")}. ` + - "The file may be corrupted.", - ); - }); -}); - -function writeLegacyJson(input: { - paseoHome: string; - projectsJson: unknown[]; - workspacesJson: unknown[]; -}): void { - const projectsPath = path.join(input.paseoHome, "projects", "projects.json"); - const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json"); - mkdirSync(path.dirname(projectsPath), { recursive: true }); - writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), { - encoding: "utf8", - flag: "w", - }); - writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), { - encoding: "utf8", - flag: "w", - }); -} diff --git a/packages/server/src/server/db/legacy-project-workspace-import.ts b/packages/server/src/server/db/legacy-project-workspace-import.ts deleted file mode 100644 index bc8ba2b46..000000000 --- a/packages/server/src/server/db/legacy-project-workspace-import.ts +++ /dev/null @@ -1,278 +0,0 @@ -import path from "node:path"; -import { promises as fs } from "node:fs"; - -import { count } from "drizzle-orm"; -import type { Logger } from "pino"; - -import { z } from "zod"; - -import type { PaseoDatabaseHandle } from "./sqlite-database.js"; -import { projects, workspaces } from "./schema.js"; - -const LEGACY_REMOTE_PREFIX = "remote:"; - -function deriveGitRemoteFromLegacyProjectId(projectId: string): string | null { - if (!projectId.startsWith(LEGACY_REMOTE_PREFIX)) { - return null; - } - const hostAndPath = projectId.slice(LEGACY_REMOTE_PREFIX.length); - return `git@${hostAndPath.replace("/", ":")}.git`; -} - -// Legacy JSON schemas — these match the old pre-migration format -const LegacyProjectSchema = z.object({ - projectId: z.string(), - rootPath: z.string(), - kind: z.string(), - displayName: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - archivedAt: z.string().nullable(), -}); - -const LegacyWorkspaceSchema = z.object({ - workspaceId: z.string(), - projectId: z.string(), - cwd: z.string(), - kind: z.string(), - displayName: z.string(), - createdAt: z.string(), - updatedAt: z.string(), - archivedAt: z.string().nullable(), -}); - -export type LegacyProjectWorkspaceImportResult = - | { - status: "imported"; - importedProjects: number; - importedWorkspaces: number; - } - | { - status: "skipped"; - reason: "database-not-empty" | "no-legacy-files"; - }; - -export async function importLegacyProjectWorkspaceJson(options: { - db: PaseoDatabaseHandle["db"]; - paseoHome: string; - logger: Logger; -}): Promise { - const projectsPath = path.join(options.paseoHome, "projects", "projects.json"); - const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json"); - const databaseHasRows = await hasAnyProjectWorkspaceRows(options.db); - - if (databaseHasRows) { - options.logger.info( - "Skipping legacy project/workspace JSON import because the DB is not empty", - ); - return { - status: "skipped", - reason: "database-not-empty", - }; - } - - const [projectsExists, workspacesExists] = await Promise.all([ - pathExists(projectsPath), - pathExists(workspacesPath), - ]); - if (!projectsExists && !workspacesExists) { - options.logger.info( - "Skipping legacy project/workspace JSON import because no legacy files exist", - ); - return { - status: "skipped", - reason: "no-legacy-files", - }; - } - - await backupLegacyProjectWorkspaceJson({ - projectsPath, - workspacesPath, - paseoHome: options.paseoHome, - logger: options.logger, - }); - - const [projectRows, workspaceRows] = await Promise.all([ - readLegacyProjects(projectsPath), - readLegacyWorkspaces(workspacesPath), - ]); - - if (projectRows.length === 0 && workspaceRows.length === 0) { - options.logger.info( - "Skipping legacy project/workspace JSON import because no legacy files exist", - ); - return { - status: "skipped", - reason: "no-legacy-files", - }; - } - - // Deduplicate legacy projects by rootPath — prefer git over non_git - const deduplicatedProjects = new Map(); - for (const legacy of projectRows) { - const existing = deduplicatedProjects.get(legacy.rootPath); - if (!existing || (legacy.kind === "git" && existing.kind !== "git")) { - deduplicatedProjects.set(legacy.rootPath, legacy); - } - } - - options.db.transaction((tx) => { - // Insert projects, mapping old format to new schema - const projectDirectoryToId = new Map(); - for (const legacy of deduplicatedProjects.values()) { - const row = tx - .insert(projects) - .values({ - directory: legacy.rootPath, - displayName: legacy.displayName, - kind: legacy.kind === "non_git" ? "directory" : legacy.kind, - gitRemote: deriveGitRemoteFromLegacyProjectId(legacy.projectId), - createdAt: legacy.createdAt, - updatedAt: legacy.updatedAt, - archivedAt: legacy.archivedAt, - }) - .returning({ id: projects.id }) - .get(); - projectDirectoryToId.set(legacy.rootPath, row!.id); - } - - // Build a map from legacy projectId -> new integer id - // Uses original projectRows so all duplicate projectIds resolve to the same new id - const legacyProjectIdToNewId = new Map(); - for (const legacy of projectRows) { - const newId = projectDirectoryToId.get(legacy.rootPath); - if (newId !== undefined) { - legacyProjectIdToNewId.set(legacy.projectId, newId); - } - } - - // Insert workspaces, resolving project FK - for (const legacy of workspaceRows) { - const projectId = legacyProjectIdToNewId.get(legacy.projectId); - if (projectId === undefined) { - throw new Error( - `Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`, - ); - } - tx.insert(workspaces) - .values({ - projectId, - directory: legacy.cwd, - displayName: legacy.displayName, - kind: - legacy.kind === "local_checkout" || legacy.kind === "directory" - ? "checkout" - : legacy.kind, - createdAt: legacy.createdAt, - updatedAt: legacy.updatedAt, - archivedAt: legacy.archivedAt, - }) - .run(); - } - }); - - const importedProjects = deduplicatedProjects.size; - - options.logger.info( - { - importedProjects, - importedWorkspaces: workspaceRows.length, - }, - "Imported legacy project/workspace JSON into the database", - ); - - return { - status: "imported", - importedProjects, - importedWorkspaces: workspaceRows.length, - }; -} - -async function readLegacyProjects(filePath: string) { - const raw = await readOptionalJsonFile(filePath); - if (!raw) { - return []; - } - try { - return z.array(LegacyProjectSchema).parse(raw); - } catch (error) { - throw new Error( - `Failed to parse ${filePath}. The file may be corrupted. ` + - `Check the file and fix or remove invalid entries. ` + - `Original error: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function readLegacyWorkspaces(filePath: string) { - const raw = await readOptionalJsonFile(filePath); - if (!raw) { - return []; - } - try { - return z.array(LegacyWorkspaceSchema).parse(raw); - } catch (error) { - throw new Error( - `Failed to parse ${filePath}. The file may be corrupted. ` + - `Check the file and fix or remove invalid entries. ` + - `Original error: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -async function readOptionalJsonFile(filePath: string): Promise { - try { - const raw = await fs.readFile(filePath, "utf8"); - return JSON.parse(raw); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return null; - } - throw error; - } -} - -async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise { - const [projectCountRows, workspaceCountRows] = await Promise.all([ - db.select({ count: count() }).from(projects), - db.select({ count: count() }).from(workspaces), - ]); - const projectCount = projectCountRows[0]?.count ?? 0; - const workspaceCount = workspaceCountRows[0]?.count ?? 0; - return projectCount > 0 || workspaceCount > 0; -} - -async function backupLegacyProjectWorkspaceJson(options: { - projectsPath: string; - workspacesPath: string; - paseoHome: string; - logger: Logger; -}): Promise { - const backupDir = path.join(options.paseoHome, "backup", "pre-migration"); - await fs.mkdir(backupDir, { recursive: true }); - - if (await pathExists(options.projectsPath)) { - await fs.copyFile(options.projectsPath, path.join(backupDir, "projects.json")); - } - if (await pathExists(options.workspacesPath)) { - await fs.copyFile(options.workspacesPath, path.join(backupDir, "workspaces.json")); - } - - options.logger.info( - { backupPath: backupDir }, - "Backed up legacy project/workspace JSON before migration", - ); -} - -async function pathExists(targetPath: string): Promise { - try { - await fs.access(targetPath); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return false; - } - throw error; - } -} diff --git a/packages/server/src/server/db/migrations.ts b/packages/server/src/server/db/migrations.ts deleted file mode 100644 index 0b42d575b..000000000 --- a/packages/server/src/server/db/migrations.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fileURLToPath } from "node:url"; - -import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import { migrate } from "drizzle-orm/better-sqlite3/migrator"; - -const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url)); - -export async function runPaseoDbMigrations( - db: BetterSQLite3Database, -): Promise { - await migrate(db, { migrationsFolder }); -} diff --git a/packages/server/src/server/db/migrations/0000_sqlite_initial.sql b/packages/server/src/server/db/migrations/0000_sqlite_initial.sql deleted file mode 100644 index 74cdb0a17..000000000 --- a/packages/server/src/server/db/migrations/0000_sqlite_initial.sql +++ /dev/null @@ -1,61 +0,0 @@ -CREATE TABLE `projects` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `directory` text NOT NULL, - `display_name` text NOT NULL, - `kind` text NOT NULL, - `git_remote` text, - `created_at` text NOT NULL, - `updated_at` text NOT NULL, - `archived_at` text -); ---> statement-breakpoint -CREATE UNIQUE INDEX `projects_directory_unique` ON `projects` (`directory`); ---> statement-breakpoint -CREATE TABLE `workspaces` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `project_id` integer NOT NULL, - `directory` text NOT NULL, - `display_name` text NOT NULL, - `kind` text NOT NULL, - `created_at` text NOT NULL, - `updated_at` text NOT NULL, - `archived_at` text, - FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE UNIQUE INDEX `workspaces_directory_unique` ON `workspaces` (`directory`); ---> statement-breakpoint -CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`); ---> statement-breakpoint -CREATE TABLE `agent_snapshots` ( - `agent_id` text PRIMARY KEY NOT NULL, - `provider` text NOT NULL, - `workspace_id` integer NOT NULL, - `cwd` text NOT NULL, - `created_at` text NOT NULL, - `updated_at` text NOT NULL, - `last_activity_at` text, - `last_user_message_at` text, - `title` text, - `labels` text NOT NULL, - `last_status` text NOT NULL, - `last_mode_id` text, - `config` text, - `runtime_info` text, - `persistence` text, - `requires_attention` integer NOT NULL, - `attention_reason` text, - `attention_timestamp` text, - `internal` integer NOT NULL, - `archived_at` text, - FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade -); ---> statement-breakpoint -CREATE TABLE `agent_timeline_rows` ( - `agent_id` text NOT NULL, - `seq` integer NOT NULL, - `committed_at` text NOT NULL, - `item` text NOT NULL, - `item_kind` text, - PRIMARY KEY(`agent_id`, `seq`) -); diff --git a/packages/server/src/server/db/migrations/meta/0000_snapshot.json b/packages/server/src/server/db/migrations/meta/0000_snapshot.json deleted file mode 100644 index 91bcfd354..000000000 --- a/packages/server/src/server/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "id": "7ccf4685-bd8f-41a6-bafb-44712c1fe0d7", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": {}, - "views": {}, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "checkConstraints": {} -} diff --git a/packages/server/src/server/db/migrations/meta/_journal.json b/packages/server/src/server/db/migrations/meta/_journal.json deleted file mode 100644 index d00e466e7..000000000 --- a/packages/server/src/server/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1774405361702, - "tag": "0000_sqlite_initial", - "breakpoints": true - } - ] -} diff --git a/packages/server/src/server/db/schema.ts b/packages/server/src/server/db/schema.ts deleted file mode 100644 index 0b193b808..000000000 --- a/packages/server/src/server/db/schema.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -import type { - AgentPersistenceHandle, - AgentRuntimeInfo, - AgentTimelineItem, -} from "../agent/agent-sdk-types.js"; -import type { StoredAgentRecord } from "../agent/agent-storage.js"; - -export const projects = sqliteTable("projects", { - id: integer("id").primaryKey({ autoIncrement: true }), - directory: text("directory").notNull().unique(), - displayName: text("display_name").notNull(), - kind: text("kind").notNull(), - gitRemote: text("git_remote"), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull(), - archivedAt: text("archived_at"), -}); - -export const workspaces = sqliteTable( - "workspaces", - { - id: integer("id").primaryKey({ autoIncrement: true }), - projectId: integer("project_id") - .notNull() - .references(() => projects.id, { onDelete: "cascade" }), - directory: text("directory").notNull().unique(), - displayName: text("display_name").notNull(), - kind: text("kind").notNull(), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull(), - archivedAt: text("archived_at"), - }, - (table) => [index("workspaces_project_id_idx").on(table.projectId)], -); - -export const agentSnapshots = sqliteTable("agent_snapshots", { - agentId: text("agent_id").primaryKey(), - provider: text("provider").notNull(), - workspaceId: integer("workspace_id") - .notNull() - .references(() => workspaces.id, { onDelete: "cascade" }), - cwd: text("cwd").notNull(), - createdAt: text("created_at").notNull(), - updatedAt: text("updated_at").notNull(), - lastActivityAt: text("last_activity_at"), - lastUserMessageAt: text("last_user_message_at"), - title: text("title"), - labels: text("labels", { mode: "json" }).$type().notNull(), - lastStatus: text("last_status").notNull(), - lastModeId: text("last_mode_id"), - config: text("config", { mode: "json" }).$type(), - runtimeInfo: text("runtime_info", { mode: "json" }).$type(), - persistence: text("persistence", { mode: "json" }).$type(), - requiresAttention: integer("requires_attention", { mode: "boolean" }).notNull(), - attentionReason: text("attention_reason"), - attentionTimestamp: text("attention_timestamp"), - internal: integer("internal", { mode: "boolean" }).notNull(), - archivedAt: text("archived_at"), -}); - -export const agentTimelineRows = sqliteTable( - "agent_timeline_rows", - { - agentId: text("agent_id").notNull(), - seq: integer("seq").notNull(), - committedAt: text("committed_at").notNull(), - item: text("item", { mode: "json" }).$type().notNull(), - itemKind: text("item_kind"), - }, - (table) => [primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" })], -); - -export const paseoDbSchema = { - projects, - workspaces, - agentSnapshots, - agentTimelineRows, -}; diff --git a/packages/server/src/server/db/sqlite-contract.test.ts b/packages/server/src/server/db/sqlite-contract.test.ts deleted file mode 100644 index b2b174f72..000000000 --- a/packages/server/src/server/db/sqlite-contract.test.ts +++ /dev/null @@ -1,326 +0,0 @@ -import os from "node:os"; -import path from "node:path"; -import { mkdtempSync, rmSync } from "node:fs"; - -import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; - -import type { AgentTimelineItem } from "../agent/agent-sdk-types.js"; -import { openPaseoDatabase } from "./sqlite-database.js"; -import { runPaseoDbMigrations } from "./migrations.js"; -import { agentSnapshots, agentTimelineRows, projects, workspaces } from "./schema.js"; - -function createTimestamp(day: number): string { - return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`; -} - -function createTimelineItem(type: AgentTimelineItem["type"], suffix: string): AgentTimelineItem { - if (type === "user_message") { - return { type, text: `user-${suffix}`, messageId: `msg-${suffix}` }; - } - if (type === "assistant_message" || type === "reasoning") { - return { type, text: `${type}-${suffix}` }; - } - return { type: "error", message: `error-${suffix}` }; -} - -describe("SQLite database contract", () => { - let tmpDir: string; - let dataDir: string; - - beforeEach(() => { - tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-db-")); - dataDir = path.join(tmpDir, "db"); - }); - - afterEach(() => { - rmSync(tmpDir, { recursive: true, force: true }); - }); - - test("creates, migrates, closes, and reopens a persistent database", async () => { - const database = await openPaseoDatabase(dataDir); - - const [project] = await database.db - .insert(projects) - .values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: "git@github.com:acme/project-1.git", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }) - .returning(); - - await database.close(); - - const reopened = await openPaseoDatabase(dataDir); - const rows = await reopened.db.select().from(projects); - expect(rows).toEqual([ - { - id: project.id, - directory: "/tmp/project-1", - displayName: "Project One", - kind: "git", - gitRemote: "git@github.com:acme/project-1.git", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }, - ]); - await reopened.close(); - }); - - test("supports project and workspace linkage plus archive field updates", async () => { - const database = await openPaseoDatabase(dataDir); - - const [project] = await database.db - .insert(projects) - .values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: null, - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }) - .returning(); - const [workspace] = await database.db - .insert(workspaces) - .values({ - projectId: project.id, - directory: "/tmp/project-1", - kind: "checkout", - displayName: "main", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }) - .returning(); - - await database.db - .update(workspaces) - .set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) }) - .where(eq(workspaces.id, workspace.id)); - - const linkedRows = await database.db - .select({ - projectId: projects.id, - workspaceId: workspaces.id, - workspaceArchivedAt: workspaces.archivedAt, - }) - .from(workspaces) - .innerJoin(projects, eq(workspaces.projectId, projects.id)); - - expect(linkedRows).toEqual([ - { - projectId: project.id, - workspaceId: workspace.id, - workspaceArchivedAt: createTimestamp(2), - }, - ]); - - await database.close(); - }); - - test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => { - const database = await openPaseoDatabase(dataDir); - const [project] = await database.db - .insert(projects) - .values({ - directory: "/tmp/project-1", - kind: "git", - displayName: "Project One", - gitRemote: null, - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }) - .returning(); - const [workspace] = await database.db - .insert(workspaces) - .values({ - projectId: project.id, - directory: "/tmp/project-1", - kind: "checkout", - displayName: "main", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - archivedAt: null, - }) - .returning(); - - await database.db.insert(agentSnapshots).values({ - agentId: "agent-1", - provider: "codex", - workspaceId: workspace.id, - cwd: "/tmp/project-1", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - lastActivityAt: createTimestamp(1), - lastUserMessageAt: null, - title: "Agent One", - labels: { surface: "workspace" }, - lastStatus: "idle", - lastModeId: "plan", - config: { model: "gpt-5.1", modeId: "plan" }, - runtimeInfo: { provider: "codex", sessionId: "session-1" }, - persistence: { provider: "codex", sessionId: "session-1" }, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: null, - }); - - await database.db - .update(agentSnapshots) - .set({ - updatedAt: createTimestamp(2), - lastStatus: "running", - title: "Agent One Updated", - archivedAt: createTimestamp(3), - }) - .where(eq(agentSnapshots.agentId, "agent-1")); - - const rows = await database.db - .select() - .from(agentSnapshots) - .where(eq(agentSnapshots.agentId, "agent-1")); - - expect(rows).toEqual([ - { - agentId: "agent-1", - provider: "codex", - workspaceId: workspace.id, - cwd: "/tmp/project-1", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(2), - lastActivityAt: createTimestamp(1), - lastUserMessageAt: null, - title: "Agent One Updated", - labels: { surface: "workspace" }, - lastStatus: "running", - lastModeId: "plan", - config: { model: "gpt-5.1", modeId: "plan" }, - runtimeInfo: { provider: "codex", sessionId: "session-1" }, - persistence: { provider: "codex", sessionId: "session-1" }, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: createTimestamp(3), - }, - ]); - - await database.db.delete(projects).where(eq(projects.id, project.id)); - expect(await database.db.select().from(workspaces)).toEqual([]); - expect(await database.db.select().from(agentSnapshots)).toEqual([]); - - await database.close(); - }); - - test("rejects agent snapshots without a workspace ID", async () => { - const database = await openPaseoDatabase(dataDir); - - await expect( - database.db.insert(agentSnapshots).values({ - agentId: "agent-1", - provider: "codex", - cwd: "/tmp/project-1", - createdAt: createTimestamp(1), - updatedAt: createTimestamp(1), - lastActivityAt: createTimestamp(1), - lastUserMessageAt: null, - title: "Agent One", - labels: { surface: "workspace" }, - lastStatus: "idle", - lastModeId: "plan", - config: { model: "gpt-5.1", modeId: "plan" }, - runtimeInfo: { provider: "codex", sessionId: "session-1" }, - persistence: { provider: "codex", sessionId: "session-1" }, - requiresAttention: false, - attentionReason: null, - attentionTimestamp: null, - internal: false, - archivedAt: null, - } as typeof agentSnapshots.$inferInsert), - ).rejects.toThrow(); - - await database.close(); - }); - - test("supports timeline append and tail, after-seq, before-seq access patterns in committed order", async () => { - const database = await openPaseoDatabase(dataDir); - const rows = [1, 2, 3, 4].map((seq) => ({ - agentId: "agent-1", - seq, - committedAt: createTimestamp(seq), - item: createTimelineItem(seq === 1 ? "user_message" : "assistant_message", String(seq)), - itemKind: seq === 1 ? "user_message" : "assistant_message", - })); - - await database.db.insert(agentTimelineRows).values(rows); - - const tailRows = await database.db - .select() - .from(agentTimelineRows) - .where(eq(agentTimelineRows.agentId, "agent-1")) - .orderBy(desc(agentTimelineRows.seq)) - .limit(2); - - expect(tailRows.map((row) => row.seq).reverse()).toEqual([3, 4]); - - const afterRows = await database.db - .select() - .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, "agent-1"), gt(agentTimelineRows.seq, 2))) - .orderBy(asc(agentTimelineRows.seq)); - - expect(afterRows.map((row) => row.seq)).toEqual([3, 4]); - - const beforeRows = await database.db - .select() - .from(agentTimelineRows) - .where(and(eq(agentTimelineRows.agentId, "agent-1"), lt(agentTimelineRows.seq, 4))) - .orderBy(desc(agentTimelineRows.seq)) - .limit(2); - - expect(beforeRows.map((row) => row.seq).reverse()).toEqual([2, 3]); - - await database.close(); - }); - - test("enforces per-agent seq uniqueness and reruns migrations without drift", async () => { - const database = await openPaseoDatabase(dataDir); - - await database.db.insert(agentTimelineRows).values({ - agentId: "agent-1", - seq: 1, - committedAt: createTimestamp(1), - item: createTimelineItem("assistant_message", "1"), - itemKind: "assistant_message", - }); - - await expect( - database.db.insert(agentTimelineRows).values({ - agentId: "agent-1", - seq: 1, - committedAt: createTimestamp(2), - item: createTimelineItem("assistant_message", "duplicate"), - itemKind: "assistant_message", - }), - ).rejects.toThrow(); - - await runPaseoDbMigrations(database.db); - - const migrationRows = database.client - .prepare("select * from __drizzle_migrations order by created_at") - .all(); - expect(migrationRows).toHaveLength(1); - - await database.close(); - }); -}); diff --git a/packages/server/src/server/db/sqlite-database.ts b/packages/server/src/server/db/sqlite-database.ts deleted file mode 100644 index 9372f4146..000000000 --- a/packages/server/src/server/db/sqlite-database.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { mkdirSync } from "node:fs"; -import path from "node:path"; - -import Database from "better-sqlite3"; -import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; - -import { runPaseoDbMigrations } from "./migrations.js"; -import { paseoDbSchema } from "./schema.js"; - -export interface PaseoDatabaseHandle { - client: Database.Database; - db: BetterSQLite3Database; - close(): Promise; -} - -export async function openPaseoDatabase(dataDir: string): Promise { - mkdirSync(dataDir, { recursive: true }); - const databasePath = path.join(dataDir, "paseo.sqlite"); - const client = new Database(databasePath); - client.pragma("foreign_keys = ON"); - client.pragma("journal_mode = WAL"); - const db = drizzle(client, { schema: paseoDbSchema }); - await runPaseoDbMigrations(db); - return { - client, - db, - async close(): Promise { - client.close(); - }, - }; -} diff --git a/packages/server/src/server/provider-history-compatibility-service.test.ts b/packages/server/src/server/provider-history-compatibility-service.test.ts index 3eb945619..f4d3829c3 100644 --- a/packages/server/src/server/provider-history-compatibility-service.test.ts +++ b/packages/server/src/server/provider-history-compatibility-service.test.ts @@ -7,8 +7,6 @@ import { describe, expect, test, vi } from "vitest"; import { AgentLoadingService } from "./agent-loading-service.js"; import { AgentManager } from "./agent/agent-manager.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; -import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js"; -import { openPaseoDatabase } from "./db/sqlite-database.js"; import { createTestAgentClients } from "./test-utils/fake-agent-client.js"; function createStoredAgentRecord(overrides?: Partial): StoredAgentRecord { @@ -57,65 +55,6 @@ function createCompatibilitySnapshot(overrides?: Partial } describe("AgentLoadingService", () => { - test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => { - const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-")); - const logger = pino({ level: "silent" }); - const database = await openPaseoDatabase(path.join(workspaceRoot, "db")); - - try { - const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger); - const manager = new AgentManager({ - clients: createTestAgentClients(), - registry: storage, - durableTimelineStore: new DbAgentTimelineStore(database.db), - logger, - idFactory: () => "00000000-0000-4000-8000-000000000301", - }); - const service = new AgentLoadingService({ - agentManager: manager as any, - agentStorage: storage as any, - logger, - }); - - const snapshot = await manager.createAgent({ - provider: "codex", - cwd: workspaceRoot, - model: "gpt-5.1-codex-mini", - }); - await manager.runAgent(snapshot.id, "say 'timeline test'"); - await manager.flush(); - await storage.flush(); - rmSync( - path.join( - os.tmpdir(), - "paseo-fake-provider-history", - "codex", - `${snapshot.persistence?.sessionId}.jsonl`, - ), - { force: true }, - ); - await manager.closeAgent(snapshot.id); - - const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id }); - const durableTimeline = await manager.fetchTimeline(snapshot.id, { - direction: "tail", - limit: 0, - }); - - expect(loaded.id).toBe(snapshot.id); - expect(manager.getTimeline(snapshot.id)).toEqual([]); - expect(durableTimeline.rows.every((row) => row.item.type === "assistant_message")).toBe(true); - expect( - durableTimeline.rows - .map((row) => (row.item.type === "assistant_message" ? row.item.text : "")) - .join(""), - ).toBe("timeline test"); - } finally { - await database.close(); - rmSync(workspaceRoot, { recursive: true, force: true }); - } - }); - test("ensureAgentLoaded succeeds when provider history is absent", async () => { const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-")); const logger = pino({ level: "silent" }); diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index e3024ff66..76370c032 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import type { Logger } from "pino"; import { AgentManager } from "../agent/agent-manager.js"; import type { ManagedAgent } from "../agent/agent-manager.js"; -import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js"; +import type { AgentStorage } from "../agent/agent-storage.js"; import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js"; import { curateAgentActivity } from "../agent/activity-curator.js"; import { @@ -98,7 +98,7 @@ export interface ScheduleServiceOptions { paseoHome: string; logger: Logger; agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; now?: () => Date; runner?: (schedule: StoredSchedule) => Promise; } @@ -107,7 +107,7 @@ export class ScheduleService { private readonly store: ScheduleStore; private readonly logger: Logger; private readonly agentManager: AgentManager; - private readonly agentStorage: AgentSnapshotStore; + private readonly agentStorage: AgentStorage; private readonly now: () => Date; private readonly runner: (schedule: StoredSchedule) => Promise; private readonly runningScheduleIds = new Set(); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 53e48ddeb..dc2192911 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -110,14 +110,24 @@ import type { ProviderSnapshotEntry, } from "./agent/agent-sdk-types.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; -import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js"; -import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js"; -import type { - PersistedProjectRecord, - PersistedWorkspaceRecord, - ProjectRegistry, - WorkspaceRegistry, +import { + normalizeWorkspaceId as normalizePersistedWorkspaceId, + deriveWorkspaceId, + deriveProjectRootPath, + deriveProjectKind, + deriveWorkspaceKind, + deriveWorkspaceDisplayName, + buildProjectPlacementForCwd as buildProjectPlacementForCwdStandalone, +} from "./workspace-registry-model.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, } from "./workspace-registry.js"; import { AgentLoadingService } from "./agent-loading-service.js"; import { @@ -156,7 +166,6 @@ import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; -import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js"; import type { LocalSpeechModelId } from "./speech/providers/local/models.js"; import { toResolver, type Resolvable } from "./speech/provider-resolver.js"; import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js"; @@ -216,13 +225,13 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean { const MAX_TERMINAL_STREAM_SLOTS = 256; -type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & { +type DeleteFencedAgentStorage = AgentStorage & { beginDelete(agentId: string): void; }; -function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void { +function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string): void { if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") { - (agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId); + (agentStorage as DeleteFencedAgentStorage).beginDelete(agentId); } } @@ -382,13 +391,6 @@ interface AudioBufferState { } // Stub types for features under development (modules not yet available) -type BackgroundGitFetchManager = { - subscribe( - opts: { repoGitRoot: string; cwd: string }, - callback: () => void, - ): Promise<{ unsubscribe: () => void }>; -}; - type AgentMcpTransportFactory = () => Promise; type VoiceTranscriptionResultPayload = { @@ -414,7 +416,7 @@ export type SessionOptions = { pushTokenStore: PushTokenStore; paseoHome: string; agentManager: AgentManager; - agentStorage: AgentSnapshotStore; + agentStorage: AgentStorage; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; chatService: FileBackedChatService; @@ -422,7 +424,6 @@ export type SessionOptions = { loopService: LoopService; checkoutDiffManager: CheckoutDiffManager; agentLoadingService?: AgentLoadingService; - backgroundGitFetchManager?: BackgroundGitFetchManager; createAgentMcpTransport?: AgentMcpTransportFactory; workspaceGitService: WorkspaceGitService; daemonConfigStore: DaemonConfigStore; @@ -624,7 +625,7 @@ export class Session { private agentMcpClient: Awaited> | null = null; private agentTools: ToolSet | null = null; private agentManager: AgentManager; - private readonly agentStorage: AgentSnapshotStore; + private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly chatService: FileBackedChatService; @@ -1418,21 +1419,14 @@ export class Session { private async findWorkspaceByDirectory(cwd: string): Promise { const normalizedCwd = await this.resolveWorkspaceDirectory(cwd); const workspaces = await this.workspaceRegistry.list(); - return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null; + return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null; } - /** - * Resolve a workspace ID that may be either a numeric ID (legacy) or a directory path - * (sent by clients that received the path-based descriptor format). - */ private async resolveWorkspaceByIdOrDirectory( workspaceId: string, ): Promise { - const numericId = Number(workspaceId); - if (!Number.isNaN(numericId)) { - const record = await this.workspaceRegistry.get(numericId); - if (record) return record; - } + const record = await this.workspaceRegistry.get(workspaceId); + if (record) return record; // Fallback: treat as directory path return this.findWorkspaceByDirectory(workspaceId); } @@ -1455,12 +1449,12 @@ export class Session { ): Promise { const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); if (!project) { - throw new Error(`Project not found for workspace ${workspace.id}`); + throw new Error(`Project not found for workspace ${workspace.workspaceId}`); } const checkout = project.kind !== "git" ? { - cwd: workspace.directory, + cwd: workspace.cwd, isGit: false as const, currentBranch: null, remoteUrl: null, @@ -1470,25 +1464,25 @@ export class Session { } : workspace.kind === "worktree" ? { - cwd: workspace.directory, + cwd: workspace.cwd, isGit: true as const, currentBranch: workspace.displayName, - remoteUrl: project.gitRemote, - worktreeRoot: workspace.directory, + remoteUrl: null, + worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: true as const, - mainRepoRoot: project.directory, + mainRepoRoot: project.rootPath, } : { - cwd: workspace.directory, + cwd: workspace.cwd, isGit: true as const, currentBranch: workspace.displayName, - remoteUrl: project.gitRemote, - worktreeRoot: workspace.directory, + remoteUrl: null, + worktreeRoot: workspace.cwd, isPaseoOwnedWorktree: false as const, mainRepoRoot: null, }; return { - projectKey: String(project.id), + projectKey: project.projectId, projectName: project.displayName, checkout, }; @@ -3071,12 +3065,12 @@ export class Session { const snapshot = await this.agentManager.createAgent( { ...sessionConfig, - cwd: resolvedWorkspace.directory, + cwd: resolvedWorkspace.cwd, }, undefined, { labels, - workspaceId: resolvedWorkspace.id, + workspaceId: resolvedWorkspace.workspaceId, initialPrompt: trimmedPrompt, }, ); @@ -4494,10 +4488,10 @@ export class Session { if (!persistedWorkspace) { continue; } - await this.syncWorkspaceGitWatchTarget(persistedWorkspace.directory, { + await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, { isGit: workspace.projectKind === "git", }); - this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.directory, workspace); + this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace); } } @@ -5014,7 +5008,7 @@ export class Session { archiveWorkspaceRecord: async (workspaceDirectory) => { const workspace = await this.findWorkspaceByDirectory(workspaceDirectory); if (workspace) { - await this.archiveWorkspaceRecord(workspace.id); + await this.archiveWorkspaceRecord(workspace.workspaceId); } }, emit: (message) => this.emit(message), @@ -5650,23 +5644,23 @@ export class Session { projectRecord ?? (await this.projectRegistry.get(workspace.projectId)); let diffStat: { additions: number; deletions: number } | null = null; - const cachedShortstat = getCachedCheckoutShortstat(workspace.directory); + const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd); if (cachedShortstat !== undefined) { diffStat = cachedShortstat; } else { - warmCheckoutShortstatInBackground(workspace.directory, undefined, () => { - void this.emitWorkspaceUpdateForCwd(workspace.directory); + warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => { + void this.emitWorkspaceUpdateForCwd(workspace.cwd); }); } return { - id: workspace.directory, - projectId: resolvedProjectRecord?.directory ?? workspace.directory, + id: workspace.cwd, + projectId: resolvedProjectRecord?.rootPath ?? workspace.cwd, projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId), - projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory, - workspaceDirectory: workspace.directory, + projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, + workspaceDirectory: workspace.cwd, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", - workspaceKind: workspace.kind === "checkout" ? "local_checkout" : workspace.kind, + workspaceKind: workspace.kind, name: workspace.displayName, status: "done", activityAt: null, @@ -5674,7 +5668,7 @@ export class Session { scripts: this.scriptRouteStore && this.scriptRuntimeStore ? buildWorkspaceScriptPayloads({ - workspaceDirectory: workspace.directory, + workspaceDirectory: workspace.cwd, routeStore: this.scriptRouteStore, runtimeStore: this.scriptRuntimeStore, daemonPort: this.getDaemonTcpPort?.() ?? null, @@ -5718,7 +5712,7 @@ export class Session { projectRecord?: PersistedProjectRecord | null, ): Promise { const base = await this.describeWorkspaceRecord(workspace, projectRecord); - const snapshot = this.workspaceGitService.peekSnapshot(workspace.directory); + const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd); if (!snapshot) { return base; } @@ -5754,7 +5748,7 @@ export class Session { const activeProjects = new Map( persistedProjects .filter((project) => !project.archivedAt) - .map((project) => [project.id, project] as const), + .map((project) => [project.projectId, project] as const), ); const descriptorsByWorkspaceId = new Map(); const workspaceIds = options.workspaceIds @@ -5765,16 +5759,16 @@ export class Session { ) : null; const workspaceIdsByDirectory = new Map( - activeRecords.map((workspace) => [workspace.directory, workspace.directory] as const), + activeRecords.map((workspace) => [workspace.cwd, workspace.cwd] as const), ); for (const workspace of activeRecords) { - if (workspaceIds && !workspaceIds.has(workspace.directory)) { + if (workspaceIds && !workspaceIds.has(workspace.cwd)) { continue; } const projectRecord = activeProjects.get(workspace.projectId) ?? null; descriptorsByWorkspaceId.set( - workspace.directory, + workspace.cwd, await this.buildWorkspaceDescriptor({ workspace, projectRecord, @@ -5814,25 +5808,23 @@ export class Session { workspaces: PersistedWorkspaceRecord[], ): string { const normalizedCwd = normalizePersistedWorkspaceId(cwd); - const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd); + const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd); if (exact) { - return exact.directory; + return exact.cwd; } let bestMatch: PersistedWorkspaceRecord | null = null; for (const workspace of workspaces) { - const prefix = workspace.directory.endsWith(sep) - ? workspace.directory - : `${workspace.directory}${sep}`; + const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`; if (!normalizedCwd.startsWith(prefix)) { continue; } - if (!bestMatch || workspace.directory.length > bestMatch.directory.length) { + if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) { bestMatch = workspace; } } - return bestMatch?.directory ?? normalizedCwd; + return bestMatch?.cwd ?? normalizedCwd; } private async listWorkspaceDescriptors(): Promise { @@ -6136,43 +6128,34 @@ export class Session { return existingWorkspace; } + const placement = await buildProjectPlacementForCwdStandalone({ + cwd: normalizedCwd, + paseoHome: this.paseoHome, + }); + const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); const timestamp = new Date().toISOString(); - const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd; - const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName); - let projectId: number | null = null; - if (gitMetadata.gitRemote) { - const existingProjects = await this.projectRegistry.list(); - const matchingProject = existingProjects.find( - (p) => p.gitRemote === gitMetadata.gitRemote && !p.archivedAt, - ); - if (matchingProject) { - projectId = matchingProject.id; - } - } - - if (projectId === null) { - projectId = await this.projectRegistry.insert({ - directory: normalizedCwd, - displayName: gitMetadata.projectDisplayName, - kind: gitMetadata.projectKind, - gitRemote: gitMetadata.gitRemote, - createdAt: timestamp, - updatedAt: timestamp, - archivedAt: null, - }); - } - - const workspaceId = await this.workspaceRegistry.insert({ - projectId, - directory: normalizedCwd, - displayName: gitMetadata.workspaceDisplayName, - kind: gitMetadata.isWorktree ? "worktree" : "checkout", + const projectRecord = createPersistedProjectRecord({ + projectId: placement.projectKey, + rootPath: deriveProjectRootPath({ cwd: normalizedCwd, checkout: placement.checkout }), + kind: deriveProjectKind(placement.checkout), + displayName: placement.projectName, createdAt: timestamp, updatedAt: timestamp, - archivedAt: null, }); - return (await this.workspaceRegistry.get(workspaceId))!; + await this.projectRegistry.upsert(projectRecord); + + const workspaceRecord = createPersistedWorkspaceRecord({ + workspaceId, + projectId: placement.projectKey, + cwd: workspaceId, + kind: deriveWorkspaceKind(placement.checkout), + displayName: deriveWorkspaceDisplayName({ cwd: workspaceId, checkout: placement.checkout }), + createdAt: timestamp, + updatedAt: timestamp, + }); + await this.workspaceRegistry.upsert(workspaceRecord); + return workspaceRecord; } private async registerPendingWorktreeWorkspace(options: { @@ -6187,48 +6170,42 @@ export class Session { throw new Error(`Workspace not found for repo root ${options.repoRoot}`); } - const projectId = Number(basePlacement.projectKey); - if (!Number.isInteger(projectId)) { - throw new Error(`Invalid project id for repo root ${options.repoRoot}`); - } - + const projectId = basePlacement.projectKey; const now = new Date().toISOString(); const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory); if (!existingWorkspace) { - const workspaceId = await this.workspaceRegistry.insert({ + const newRecord = createPersistedWorkspaceRecord({ + workspaceId: workspaceDirectory, projectId, - directory: workspaceDirectory, + cwd: workspaceDirectory, displayName: options.branchName, kind: "worktree", createdAt: now, updatedAt: now, - archivedAt: null, }); - const workspace = await this.workspaceRegistry.get(workspaceId); - if (!workspace) { - throw new Error(`Workspace not found after insert: ${workspaceId}`); - } - await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true }); - return workspace; + await this.workspaceRegistry.upsert(newRecord); + await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); + return newRecord; } - await this.workspaceRegistry.upsert({ - id: existingWorkspace.id, - projectId, - directory: workspaceDirectory, - displayName: options.branchName, - kind: "worktree", - createdAt: existingWorkspace.createdAt, - updatedAt: now, - archivedAt: null, - }); + await this.workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: existingWorkspace.workspaceId, + projectId, + cwd: workspaceDirectory, + displayName: options.branchName, + kind: "worktree", + createdAt: existingWorkspace.createdAt, + updatedAt: now, + }), + ); await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) { const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => workspace.projectId === existingWorkspace.projectId && - workspace.id !== existingWorkspace.id && + workspace.workspaceId !== existingWorkspace.workspaceId && !workspace.archivedAt, ); if (siblingWorkspaces.length === 0) { @@ -6236,21 +6213,21 @@ export class Session { } } - return (await this.workspaceRegistry.get(existingWorkspace.id))!; + return (await this.workspaceRegistry.get(existingWorkspace.workspaceId))!; } - private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise { + private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise { const existingWorkspace = await this.workspaceRegistry.get(workspaceId); if (!existingWorkspace || existingWorkspace.archivedAt) { - this.removeWorkspaceGitSubscription(String(workspaceId)); + this.removeWorkspaceGitSubscription(workspaceId); return; } const nextArchivedAt = archivedAt ?? new Date().toISOString(); await this.workspaceRegistry.archive(workspaceId, nextArchivedAt); - await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory); - this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory); - this.removeWorkspaceGitSubscription(String(workspaceId)); + await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd); + this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd); + this.removeWorkspaceGitSubscription(workspaceId); const siblingWorkspaces = (await this.workspaceRegistry.list()).filter( (workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt, @@ -6506,7 +6483,7 @@ export class Session { ): Promise { try { const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd); - await this.emitWorkspaceUpdateForCwd(workspace.directory); + await this.emitWorkspaceUpdateForCwd(workspace.cwd); const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); this.emit({ type: "open_project_response", @@ -6577,9 +6554,9 @@ export class Session { } const serviceResult = await spawnWorkspaceScript({ - repoRoot: workspace.directory, - workspaceId: workspace.directory, - branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"), + repoRoot: workspace.cwd, + workspaceId: workspace.cwd, + branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"), scriptName: request.scriptName, daemonPort: this.getDaemonTcpPort?.() ?? null, daemonListenHost: this.getDaemonTcpHost?.() ?? null, @@ -6588,11 +6565,11 @@ export class Session { terminalManager: this.terminalManager, logger: this.sessionLogger, onLifecycleChanged: () => { - this.emitWorkspaceScriptStatusUpdate(workspace.directory); + this.emitWorkspaceScriptStatusUpdate(workspace.cwd); }, }); - this.emitWorkspaceScriptStatusUpdate(workspace.directory); + this.emitWorkspaceScriptStatusUpdate(workspace.cwd); this.emit({ type: "start_workspace_script_response", payload: { @@ -6709,7 +6686,7 @@ export class Session { private async runWorktreeSetupInBackground(options: { requestCwd: string; repoRoot: string; - workspaceId: number; + workspaceId: string; worktree: { branchName: string; worktreePath: string }; shouldBootstrap: boolean; slug: string; @@ -6757,8 +6734,8 @@ export class Session { throw new Error("Use worktree archive for Paseo worktrees"); } const archivedAt = new Date().toISOString(); - await this.archiveWorkspaceRecord(existing.id, archivedAt); - await this.emitWorkspaceUpdateForCwd(existing.directory); + await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt); + await this.emitWorkspaceUpdateForCwd(existing.cwd); this.emit({ type: "archive_workspace_response", payload: { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index a3e3a00b6..ff325460a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -3,7 +3,7 @@ import type { Server as HTTPServer } from "http"; import { join } from "path"; import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; -import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; import type { DownloadTokenStore } from "./file-download/token-store.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type pino from "pino"; @@ -74,7 +74,6 @@ function createNoopProjectRegistry(): ProjectRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, - insert: async () => 0, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -87,7 +86,6 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry { existsOnDisk: async () => true, list: async () => [], get: async () => null, - insert: async () => 0, upsert: async () => {}, archive: async () => {}, remove: async () => {}, @@ -234,7 +232,7 @@ export class VoiceAssistantWebSocketServer { private readonly serverId: string; private readonly daemonVersion: string; private readonly agentManager: AgentManager; - private readonly agentStorage: AgentSnapshotStore; + private readonly agentStorage: AgentStorage; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; private readonly chatService: FileBackedChatService; @@ -298,7 +296,7 @@ export class VoiceAssistantWebSocketServer { logger: pino.Logger, serverId: string, agentManager: AgentManager, - agentStorage: AgentSnapshotStore, + agentStorage: AgentStorage, downloadTokenStore: DownloadTokenStore, paseoHome: string, daemonConfigStore: DaemonConfigStore, diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 34379b490..4ba45dd9d 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -11,17 +11,17 @@ import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js"; const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; export type ReconciliationChange = - | { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string } - | { kind: "project_archived"; projectId: number; directory: string; reason: string } + | { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string } + | { kind: "project_archived"; projectId: string; directory: string; reason: string } | { kind: "project_updated"; - projectId: number; + projectId: string; directory: string; - fields: Partial>; + fields: Partial>; } | { kind: "workspace_updated"; - workspaceId: number; + workspaceId: string; directory: string; fields: Partial>; }; @@ -103,7 +103,7 @@ export class WorkspaceReconciliationService { const activeProjects = allProjects.filter((p) => !p.archivedAt); const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt); - const workspacesByProject = new Map(); + const workspacesByProject = new Map(); for (const workspace of activeWorkspaces) { const list = workspacesByProject.get(workspace.projectId) ?? []; list.push(workspace); @@ -112,20 +112,20 @@ export class WorkspaceReconciliationService { // 1. Archive workspaces whose directories no longer exist for (const workspace of activeWorkspaces) { - if (!existsSync(workspace.directory)) { + if (!existsSync(workspace.cwd)) { const timestamp = new Date().toISOString(); - await this.workspaceRegistry.archive(workspace.id, timestamp); + await this.workspaceRegistry.archive(workspace.workspaceId, timestamp); changes.push({ kind: "workspace_archived", - workspaceId: workspace.id, - directory: workspace.directory, + workspaceId: workspace.workspaceId, + directory: workspace.cwd, reason: "directory_missing", }); // Update the in-memory list for the project orphan check below const siblings = workspacesByProject.get(workspace.projectId); if (siblings) { - const updated = siblings.filter((w) => w.id !== workspace.id); + const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId); workspacesByProject.set(workspace.projectId, updated); } } @@ -133,14 +133,14 @@ export class WorkspaceReconciliationService { // 2. Archive orphaned projects (all workspaces archived/removed) for (const project of activeProjects) { - const siblings = workspacesByProject.get(project.id) ?? []; + const siblings = workspacesByProject.get(project.projectId) ?? []; if (siblings.length === 0) { const timestamp = new Date().toISOString(); - await this.projectRegistry.archive(project.id, timestamp); + await this.projectRegistry.archive(project.projectId, timestamp); changes.push({ kind: "project_archived", - projectId: project.id, - directory: project.directory, + projectId: project.projectId, + directory: project.rootPath, reason: "no_active_workspaces", }); } @@ -149,23 +149,24 @@ export class WorkspaceReconciliationService { // 3. Reconcile git metadata for active projects whose directories still exist for (const project of activeProjects) { if (project.archivedAt) continue; - const siblings = workspacesByProject.get(project.id) ?? []; + const siblings = workspacesByProject.get(project.projectId) ?? []; if (siblings.length === 0) continue; - if (!existsSync(project.directory)) continue; + if (!existsSync(project.rootPath)) continue; const directoryName = - project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory; - const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName); + project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath; + const currentGit = detectWorkspaceGitMetadata(project.rootPath, directoryName); const projectUpdates: Partial< - Pick + Pick > = {}; + const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git"; + // Detect kind change: directory → git - if (project.kind !== currentGit.projectKind) { - projectUpdates.kind = currentGit.projectKind; + if (project.kind !== mappedKind) { + projectUpdates.kind = mappedKind; projectUpdates.displayName = currentGit.projectDisplayName; - projectUpdates.gitRemote = currentGit.gitRemote; } // Detect display name change (e.g. remote renamed) @@ -177,15 +178,6 @@ export class WorkspaceReconciliationService { projectUpdates.displayName = currentGit.projectDisplayName; } - // Detect git remote change - if ( - project.kind === "git" && - currentGit.projectKind === "git" && - project.gitRemote !== currentGit.gitRemote - ) { - projectUpdates.gitRemote = currentGit.gitRemote; - } - if (Object.keys(projectUpdates).length > 0) { const timestamp = new Date().toISOString(); await this.projectRegistry.upsert({ @@ -195,19 +187,18 @@ export class WorkspaceReconciliationService { }); changes.push({ kind: "project_updated", - projectId: project.id, - directory: project.directory, + projectId: project.projectId, + directory: project.rootPath, fields: projectUpdates, }); } // 4. Reconcile workspace display names (branch name changes) for (const workspace of siblings) { - if (!existsSync(workspace.directory)) continue; + if (!existsSync(workspace.cwd)) continue; - const wsDirName = - workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory; - const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName); + const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd; + const wsGit = detectWorkspaceGitMetadata(workspace.cwd, wsDirName); if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { const timestamp = new Date().toISOString(); @@ -218,8 +209,8 @@ export class WorkspaceReconciliationService { }); changes.push({ kind: "workspace_updated", - workspaceId: workspace.id, - directory: workspace.directory, + workspaceId: workspace.workspaceId, + directory: workspace.cwd, fields: { displayName: wsGit.workspaceDisplayName }, }); } diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts new file mode 100644 index 000000000..c9403f810 --- /dev/null +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -0,0 +1,150 @@ +import path from "node:path"; + +import type { Logger } from "pino"; + +import type { StoredAgentRecord } from "./agent/agent-storage.js"; +import type { AgentStorage } from "./agent/agent-storage.js"; +import { + buildProjectPlacementForCwd, + deriveWorkspaceId, + deriveProjectKind, + deriveProjectRootPath, + deriveWorkspaceDisplayName, + deriveWorkspaceKind, + normalizeWorkspaceId, +} from "./workspace-registry-model.js"; +import { + createPersistedProjectRecord, + createPersistedWorkspaceRecord, + type ProjectRegistry, + type WorkspaceRegistry, +} from "./workspace-registry.js"; + +function minIsoDate(left: string | null, right: string | null): string | null { + if (!left) { + return right; + } + if (!right) { + return left; + } + return Date.parse(left) <= Date.parse(right) ? left : right; +} + +function maxIsoDate(left: string | null, right: string | null): string | null { + if (!left) { + return right; + } + if (!right) { + return left; + } + return Date.parse(left) >= Date.parse(right) ? left : right; +} + +function resolveAgentCreatedAt(record: StoredAgentRecord): string { + return record.createdAt || record.updatedAt || new Date(0).toISOString(); +} + +function resolveAgentUpdatedAt(record: StoredAgentRecord): string { + return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString(); +} + +export async function bootstrapWorkspaceRegistries(options: { + paseoHome: string; + agentStorage: AgentStorage; + projectRegistry: ProjectRegistry; + workspaceRegistry: WorkspaceRegistry; + logger: Logger; +}): Promise { + const [projectsExists, workspacesExists] = await Promise.all([ + options.projectRegistry.existsOnDisk(), + options.workspaceRegistry.existsOnDisk(), + ]); + + await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]); + + if (projectsExists && workspacesExists) { + return; + } + + const records = await options.agentStorage.list(); + const activeRecords = records.filter((record) => !record.archivedAt); + const recordsByWorkspaceId = new Map< + string, + { + placement: Awaited>; + records: StoredAgentRecord[]; + } + >(); + for (const record of activeRecords) { + const normalizedCwd = normalizeWorkspaceId(record.cwd); + const placement = await buildProjectPlacementForCwd({ + cwd: normalizedCwd, + paseoHome: options.paseoHome, + }); + const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); + const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; + existing.records.push(record); + recordsByWorkspaceId.set(workspaceId, existing); + } + + const projectRanges = new Map(); + + for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { + const { placement, records: workspaceRecords } = entry; + let workspaceCreatedAt: string | null = null; + let workspaceUpdatedAt: string | null = null; + for (const record of workspaceRecords) { + workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record)); + workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record)); + } + + const createdAt = workspaceCreatedAt ?? new Date().toISOString(); + const updatedAt = workspaceUpdatedAt ?? createdAt; + await options.workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId, + projectId: placement.projectKey, + cwd: workspaceId, + kind: deriveWorkspaceKind(placement.checkout), + displayName: deriveWorkspaceDisplayName({ + cwd: workspaceId, + checkout: placement.checkout, + }), + createdAt, + updatedAt, + }), + ); + + const existingProjectRange = projectRanges.get(placement.projectKey) ?? { + createdAt: null, + updatedAt: null, + }; + existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt); + existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt); + projectRanges.set(placement.projectKey, existingProjectRange); + + await options.projectRegistry.upsert( + createPersistedProjectRecord({ + projectId: placement.projectKey, + rootPath: deriveProjectRootPath({ + cwd: workspaceId, + checkout: placement.checkout, + }), + kind: deriveProjectKind(placement.checkout), + displayName: placement.projectName, + createdAt: existingProjectRange.createdAt ?? createdAt, + updatedAt: existingProjectRange.updatedAt ?? updatedAt, + }), + ); + } + + options.logger.info( + { + projectsFile: path.join(options.paseoHome, "projects", "projects.json"), + workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"), + materializedProjects: projectRanges.size, + materializedWorkspaces: recordsByWorkspaceId.size, + }, + "Workspace registries bootstrapped from existing agent storage", + ); +} diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 1d1a08f0c..43dde14fa 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -1,7 +1,15 @@ import { resolve } from "node:path"; -export type PersistedProjectKind = "git" | "directory"; -export type PersistedWorkspaceKind = "checkout" | "worktree"; +import { getCheckoutStatusLite } from "../utils/checkout-git.js"; +import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js"; +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; + +export type PersistedProjectKind = "git" | "non_git"; +export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory"; +export type DetectStaleWorkspacesInput = { + activeWorkspaces: PersistedWorkspaceRecord[]; + checkDirectoryExists: (cwd: string) => Promise; +}; export function normalizeWorkspaceId(cwd: string): string { const trimmed = cwd.trim(); @@ -10,3 +18,203 @@ export function normalizeWorkspaceId(cwd: string): string { } return resolve(trimmed); } + +export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string { + return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd); +} + +function deriveRemoteProjectKey(remoteUrl: string | null): string | null { + if (!remoteUrl) { + return null; + } + + const trimmed = remoteUrl.trim(); + if (!trimmed) { + return null; + } + + let host: string | null = null; + let remotePath: string | null = null; + + const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/); + if (scpLike) { + host = scpLike[1] ?? null; + remotePath = scpLike[2] ?? null; + } else if (trimmed.includes("://")) { + try { + const parsed = new URL(trimmed); + host = parsed.hostname || null; + remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null; + } catch { + return null; + } + } + + if (!host || !remotePath) { + return null; + } + + let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, ""); + if (cleanedPath.endsWith(".git")) { + cleanedPath = cleanedPath.slice(0, -4); + } + if (!cleanedPath.includes("/")) { + return null; + } + + const cleanedHost = host.toLowerCase(); + if (cleanedHost === "github.com") { + return `remote:github.com/${cleanedPath}`; + } + + return `remote:${cleanedHost}/${cleanedPath}`; +} + +export function deriveProjectGroupingKey(options: { + cwd: string; + remoteUrl: string | null; + isPaseoOwnedWorktree: boolean; + mainRepoRoot: string | null; +}): string { + const remoteKey = deriveRemoteProjectKey(options.remoteUrl); + if (remoteKey) { + return remoteKey; + } + + const mainRepoRoot = options.mainRepoRoot?.trim(); + if (options.isPaseoOwnedWorktree && mainRepoRoot) { + return mainRepoRoot; + } + + return options.cwd; +} + +export function deriveProjectGroupingName(projectKey: string): string { + const githubRemotePrefix = "remote:github.com/"; + if (projectKey.startsWith(githubRemotePrefix)) { + return projectKey.slice(githubRemotePrefix.length) || projectKey; + } + + const segments = projectKey.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] || projectKey; +} + +function deriveWorkspaceDirectoryName(cwd: string): string { + const normalized = cwd.replace(/\\/g, "/"); + const segments = normalized.split("/").filter(Boolean); + return segments[segments.length - 1] ?? cwd; +} + +export function deriveWorkspaceDisplayName(input: { + cwd: string; + checkout: ProjectCheckoutLitePayload; +}): string { + const branch = input.checkout.currentBranch?.trim() ?? null; + if (branch && branch.toUpperCase() !== "HEAD") { + return branch; + } + return deriveWorkspaceDirectoryName(input.cwd); +} + +export function deriveProjectRootPath(input: { + cwd: string; + checkout: ProjectCheckoutLitePayload; +}): string { + if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) { + return input.checkout.mainRepoRoot; + } + return input.cwd; +} + +export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind { + return checkout.isGit ? "git" : "non_git"; +} + +export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind { + if (!checkout.isGit) { + return "directory"; + } + return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout"; +} + +export async function detectStaleWorkspaces( + input: DetectStaleWorkspacesInput, +): Promise> { + const staleWorkspaceIds = new Set(); + + for (const workspace of input.activeWorkspaces) { + const dirExists = await input.checkDirectoryExists(workspace.cwd); + if (!dirExists) { + staleWorkspaceIds.add(workspace.workspaceId); + } + } + + return staleWorkspaceIds; +} + +export async function buildProjectPlacementForCwd(input: { + cwd: string; + paseoHome: string; +}): Promise { + const normalizedCwd = normalizeWorkspaceId(input.cwd); + const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome }) + .then((status): ProjectCheckoutLitePayload => { + if (!status.isGit) { + return { + cwd: normalizedCwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + } + + if (status.isPaseoOwnedWorktree && status.mainRepoRoot) { + return { + cwd: normalizedCwd, + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, + isPaseoOwnedWorktree: true, + mainRepoRoot: status.mainRepoRoot, + }; + } + + return { + cwd: normalizedCwd, + isGit: true, + currentBranch: status.currentBranch, + remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }; + }) + .catch( + (): ProjectCheckoutLitePayload => ({ + cwd: normalizedCwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + ); + + const projectKey = deriveProjectGroupingKey({ + cwd: checkout.worktreeRoot ?? normalizedCwd, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.mainRepoRoot, + }); + + return { + projectKey, + projectName: deriveProjectGroupingName(projectKey), + checkout, + }; +} diff --git a/packages/server/src/server/workspace-registry.test-helpers.ts b/packages/server/src/server/workspace-registry.test-helpers.ts index 5047a3c32..7ce1e0b4a 100644 --- a/packages/server/src/server/workspace-registry.test-helpers.ts +++ b/packages/server/src/server/workspace-registry.test-helpers.ts @@ -5,12 +5,12 @@ import path from "node:path"; import type { Logger } from "pino"; import { - parsePersistedProjectRecords, - parsePersistedWorkspaceRecords, type PersistedProjectRecord, type PersistedWorkspaceRecord, type ProjectRegistry, type WorkspaceRegistry, + createPersistedProjectRecord, + createPersistedWorkspaceRecord, } from "./workspace-registry.js"; type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; @@ -18,9 +18,8 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; class FileBackedRegistry { private readonly filePath: string; private readonly logger: Logger; - private readonly parseRecord: (record: unknown) => TRecord; - private readonly parseRecords: (input: unknown) => TRecord[]; - private readonly getId: (record: TRecord) => number; + private readonly schema: (record: unknown) => TRecord; + private readonly getId: (record: TRecord) => string; private loaded = false; private readonly cache = new Map(); private persistQueue: Promise = Promise.resolve(); @@ -28,13 +27,12 @@ class FileBackedRegistry { constructor(options: { filePath: string; logger: Logger; - parseRecords: (input: unknown) => TRecord[]; - getId: (record: TRecord) => number; + schema: (record: unknown) => TRecord; + getId: (record: TRecord) => string; component: string; }) { this.filePath = options.filePath; - this.parseRecords = options.parseRecords; - this.parseRecord = (record) => options.parseRecords([record])[0]!; + this.schema = options.schema; this.getId = options.getId; this.logger = options.logger.child({ module: "workspace-registry", @@ -60,47 +58,36 @@ class FileBackedRegistry { return Array.from(this.cache.values()); } - async get(id: number): Promise { + async get(id: string): Promise { await this.load(); - return this.cache.get(String(id)) ?? null; - } - - async insert(record: Omit): Promise { - await this.load(); - const nextId = - Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1; - const parsed = this.parseRecord({ ...record, id: nextId }); - this.cache.set(String(this.getId(parsed)), parsed); - await this.enqueuePersist(); - return nextId; + return this.cache.get(id) ?? null; } async upsert(record: TRecord): Promise { await this.load(); - const parsed = this.parseRecord(record); - this.cache.set(String(this.getId(parsed)), parsed); + const parsed = this.schema(record); + this.cache.set(this.getId(parsed), parsed); await this.enqueuePersist(); } - async archive(id: number, archivedAt: string): Promise { + async archive(id: string, archivedAt: string): Promise { await this.load(); - const key = String(id); - const existing = this.cache.get(key); + const existing = this.cache.get(id); if (!existing) { return; } - const next = this.parseRecord({ + const next = this.schema({ ...existing, updatedAt: archivedAt, archivedAt, }); - this.cache.set(key, next); + this.cache.set(id, next); await this.enqueuePersist(); } - async remove(id: number): Promise { + async remove(id: string): Promise { await this.load(); - if (!this.cache.delete(String(id))) { + if (!this.cache.delete(id)) { return; } await this.enqueuePersist(); @@ -114,9 +101,10 @@ class FileBackedRegistry { this.cache.clear(); try { const raw = await fs.readFile(this.filePath, "utf8"); - const parsed = this.parseRecords(JSON.parse(raw)); + const parsed = JSON.parse(raw) as TRecord[]; for (const record of parsed) { - this.cache.set(String(this.getId(record)), record); + const validated = this.schema(record); + this.cache.set(this.getId(validated), validated); } } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -150,8 +138,8 @@ export class FileBackedProjectRegistry super({ filePath, logger, - parseRecords: parsePersistedProjectRecords, - getId: (record) => record.id, + schema: (record) => createPersistedProjectRecord(record as PersistedProjectRecord), + getId: (record) => record.projectId, component: "projects", }); } @@ -165,8 +153,8 @@ export class FileBackedWorkspaceRegistry super({ filePath, logger, - parseRecords: parsePersistedWorkspaceRecords, - getId: (record) => record.id, + schema: (record) => createPersistedWorkspaceRecord(record as PersistedWorkspaceRecord), + getId: (record) => record.workspaceId, component: "workspaces", }); } diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index d02010509..dcd7fb411 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -1,21 +1,27 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import type { Logger } from "pino"; import { z } from "zod"; +import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js"; + const PersistedProjectRecordSchema = z.object({ - id: z.number().int(), - directory: z.string(), - kind: z.enum(["git", "directory"]), + projectId: z.string(), + rootPath: z.string(), + kind: z.enum(["git", "non_git"]), displayName: z.string(), - gitRemote: z.string().nullable(), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), }); const PersistedWorkspaceRecordSchema = z.object({ - id: z.number().int(), - projectId: z.number().int(), - directory: z.string(), - kind: z.enum(["checkout", "worktree"]), + workspaceId: z.string(), + projectId: z.string(), + cwd: z.string(), + kind: z.enum(["local_checkout", "worktree", "directory"]), displayName: z.string(), createdAt: z.string(), updatedAt: z.string(), @@ -25,58 +31,192 @@ const PersistedWorkspaceRecordSchema = z.object({ export type PersistedProjectRecord = z.infer; export type PersistedWorkspaceRecord = z.infer; -export function parsePersistedProjectRecords(input: unknown): PersistedProjectRecord[] { - return z.array(PersistedProjectRecordSchema).parse(input); -} - -export function parsePersistedWorkspaceRecords(input: unknown): PersistedWorkspaceRecord[] { - return z.array(PersistedWorkspaceRecordSchema).parse(input); -} - export interface ProjectRegistry { initialize(): Promise; existsOnDisk(): Promise; list(): Promise; - get(id: number): Promise; - insert(record: Omit): Promise; + get(projectId: string): Promise; upsert(record: PersistedProjectRecord): Promise; - archive(id: number, archivedAt: string): Promise; - remove(id: number): Promise; + archive(projectId: string, archivedAt: string): Promise; + remove(projectId: string): Promise; } export interface WorkspaceRegistry { initialize(): Promise; existsOnDisk(): Promise; list(): Promise; - get(id: number): Promise; - insert(record: Omit): Promise; + get(workspaceId: string): Promise; upsert(record: PersistedWorkspaceRecord): Promise; - archive(id: number, archivedAt: string): Promise; - remove(id: number): Promise; + archive(workspaceId: string, archivedAt: string): Promise; + remove(workspaceId: string): Promise; +} + +type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord; + +class FileBackedRegistry { + private readonly filePath: string; + private readonly logger: Logger; + private readonly schema: z.ZodSchema; + private readonly getId: (record: TRecord) => string; + private loaded = false; + private readonly cache = new Map(); + private persistQueue: Promise = Promise.resolve(); + + constructor(options: { + filePath: string; + logger: Logger; + schema: z.ZodSchema; + getId: (record: TRecord) => string; + component: string; + }) { + this.filePath = options.filePath; + this.schema = options.schema; + this.getId = options.getId; + this.logger = options.logger.child({ + module: "workspace-registry", + component: options.component, + }); + } + + async initialize(): Promise { + await this.load(); + } + + async existsOnDisk(): Promise { + try { + await fs.access(this.filePath); + return true; + } catch { + return false; + } + } + + async list(): Promise { + await this.load(); + return Array.from(this.cache.values()); + } + + async get(id: string): Promise { + await this.load(); + return this.cache.get(id) ?? null; + } + + async upsert(record: TRecord): Promise { + await this.load(); + const parsed = this.schema.parse(record); + this.cache.set(this.getId(parsed), parsed); + await this.enqueuePersist(); + } + + async archive(id: string, archivedAt: string): Promise { + await this.load(); + const existing = this.cache.get(id); + if (!existing) { + return; + } + const next = this.schema.parse({ + ...existing, + updatedAt: archivedAt, + archivedAt, + }); + this.cache.set(id, next); + await this.enqueuePersist(); + } + + async remove(id: string): Promise { + await this.load(); + if (!this.cache.delete(id)) { + return; + } + await this.enqueuePersist(); + } + + private async load(): Promise { + if (this.loaded) { + return; + } + + this.cache.clear(); + try { + const raw = await fs.readFile(this.filePath, "utf8"); + const parsed = z.array(this.schema).parse(JSON.parse(raw)); + for (const record of parsed) { + this.cache.set(this.getId(record), record); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file"); + } + } + this.loaded = true; + } + + private async persist(): Promise { + const records = Array.from(this.cache.values()); + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8"); + await fs.rename(tempPath, this.filePath); + } + + private async enqueuePersist(): Promise { + const nextPersist = this.persistQueue.then(() => this.persist()); + this.persistQueue = nextPersist.catch(() => {}); + await nextPersist; + } +} + +export class FileBackedProjectRegistry + extends FileBackedRegistry + implements ProjectRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + schema: PersistedProjectRecordSchema, + getId: (record) => record.projectId, + component: "projects", + }); + } +} + +export class FileBackedWorkspaceRegistry + extends FileBackedRegistry + implements WorkspaceRegistry +{ + constructor(filePath: string, logger: Logger) { + super({ + filePath, + logger, + schema: PersistedWorkspaceRecordSchema, + getId: (record) => record.workspaceId, + component: "workspaces", + }); + } } export function createPersistedProjectRecord(input: { - id: number; - directory: string; - kind: "git" | "directory"; + projectId: string; + rootPath: string; + kind: PersistedProjectKind; displayName: string; - gitRemote?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; }): PersistedProjectRecord { return PersistedProjectRecordSchema.parse({ ...input, - gitRemote: input.gitRemote ?? null, archivedAt: input.archivedAt ?? null, }); } export function createPersistedWorkspaceRecord(input: { - id: number; - projectId: number; - directory: string; - kind: "checkout" | "worktree"; + workspaceId: string; + projectId: string; + cwd: string; + kind: PersistedWorkspaceKind; displayName: string; createdAt: string; updatedAt: string; diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index eb84c369d..ec8cf5c18 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -89,10 +89,10 @@ type ArchivePaseoWorktreeDependencies = { type RegisterPendingWorktreeWorkspaceDependencies = { buildProjectPlacement: (cwd: string) => Promise; findWorkspaceByDirectory: (directory: string) => Promise; - projectRegistry: Pick; + projectRegistry: Pick; syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise; - workspaceRegistry: Pick; - archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise; + workspaceRegistry: Pick; + archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise; }; type CreatePaseoWorktreeInBackgroundDependencies = { @@ -102,7 +102,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = { emit: EmitSessionMessage; sessionLogger: Logger; terminalManager: TerminalManager | null; - archiveWorkspaceRecord: (workspaceId: number) => Promise; + archiveWorkspaceRecord: (workspaceId: string) => Promise; }; type HandleWorkspaceSetupStatusRequestDependencies = { @@ -126,7 +126,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = { runWorktreeSetupInBackground: (options: { requestCwd: string; repoRoot: string; - workspaceId: number; + workspaceId: string; worktree: WorktreeConfig; shouldBootstrap: boolean; slug: string; @@ -595,34 +595,33 @@ export async function registerPendingWorktreeWorkspace( ): Promise { const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath); const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot); - const projectId = Number(basePlacement.projectKey); - if (!Number.isInteger(projectId)) { - throw new Error(`Invalid project id for repo root ${options.repoRoot}`); - } + const projectId = basePlacement.projectKey; const now = new Date().toISOString(); const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory); if (!existingWorkspace) { - const workspaceId = await dependencies.workspaceRegistry.insert({ + const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = { + workspaceId: workspaceDirectory, projectId, - directory: workspaceDirectory, + cwd: workspaceDirectory, displayName: options.branchName, kind: "worktree", createdAt: now, updatedAt: now, archivedAt: null, - }); - const workspace = await dependencies.workspaceRegistry.get(workspaceId); + }; + await dependencies.workspaceRegistry.upsert(newRecord); + const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory); if (!workspace) { - throw new Error(`Workspace not found after insert: ${workspaceId}`); + throw new Error(`Workspace not found after upsert: ${workspaceDirectory}`); } await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true }); return workspace; } await dependencies.workspaceRegistry.upsert({ - id: existingWorkspace.id, + workspaceId: existingWorkspace.workspaceId, projectId, - directory: workspaceDirectory, + cwd: workspaceDirectory, displayName: options.branchName, kind: "worktree", createdAt: existingWorkspace.createdAt, @@ -635,7 +634,7 @@ export async function registerPendingWorktreeWorkspace( await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now); } - return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!; + return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!; } export async function handleCreatePaseoWorktreeRequest( @@ -710,7 +709,7 @@ export async function handleCreatePaseoWorktreeRequest( void dependencies.runWorktreeSetupInBackground({ requestCwd: request.cwd, repoRoot, - workspaceId: workspace.id, + workspaceId: workspace.workspaceId, worktree: createdWorktree.worktree, shouldBootstrap: createdWorktree.shouldBootstrap, slug: normalizedSlug, @@ -744,9 +743,9 @@ export async function handleWorkspaceSetupStatusRequest( // Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup if (!snapshot && Number.isNaN(Number(workspaceId))) { const workspaces = await dependencies.workspaceRegistry.list(); - const match = workspaces.find((w) => w.directory === workspaceId && !w.archivedAt); + const match = workspaces.find((w) => w.cwd === workspaceId && !w.archivedAt); if (match) { - snapshot = dependencies.workspaceSetupSnapshots.get(String(match.id)) ?? null; + snapshot = dependencies.workspaceSetupSnapshots.get(match.workspaceId) ?? null; } } @@ -765,7 +764,7 @@ export async function runWorktreeSetupInBackground( options: { requestCwd: string; repoRoot: string; - workspaceId: number; + workspaceId: string; worktree: WorktreeConfig; shouldBootstrap: boolean; slug: string;