Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 831dba9122 Add database query CLI tool for development 2026-03-25 20:39:08 +07:00
Mohamed Boudra 296f283d44 Eliminate full timeline memory load: route committed queries to DB 2026-03-25 20:39:08 +07:00
Mohamed Boudra f27c803bf8 fix(server): keep Claude model queries open 2026-03-25 20:39:08 +07:00
Mohamed Boudra ce8128910e Fix streaming double-dispatch: suppress raw chunks during provisional buffering 2026-03-25 20:39:08 +07:00
Mohamed Boudra c2d8cbdb2e Fix delete race: skip background persist for closed agents 2026-03-25 20:39:08 +07:00
Mohamed Boudra 05094fe9e1 Phase 5: remove legacy storage paths and dead compatibility code 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6027c38f8c Phase 4c: skip provider-history rehydrate when DB timeline is available 2026-03-25 20:39:07 +07:00
Mohamed Boudra eae83a53d8 Phase 4b: timeline read authority cutover to DB 2026-03-25 20:39:07 +07:00
Mohamed Boudra 17dc2801db Phase 4a: durable timeline store adapter + dual-write 2026-03-25 20:39:07 +07:00
Mohamed Boudra 3e9fd00501 Phase 3b: agent snapshot DB cutover + interface extraction 2026-03-25 20:39:07 +07:00
Mohamed Boudra 307a6e85f9 Phase 3a: project/workspace DB cutover + legacy JSON import
Bootstrap now opens PGlite and uses DB-backed project/workspace
registries as the sole runtime authority. Legacy JSON files are
imported transactionally on first startup; restart is idempotent.

Key changes:
- bootstrap.ts: DB registries replace file-backed, DB lifecycle
  managed on startup/shutdown/error
- workspace-registry-bootstrap.ts: fixed FK write order (project
  before workspace), uses list() instead of existsOnDisk()
- New transactional import module with rollback safety
- Bootstrap smoke tests for import, idempotence, and reconciliation

Audit-verified: authority cutover (13/13 checks), boundary (10/10).
2026-03-25 20:39:07 +07:00
Mohamed Boudra 7f11c0c64c Phase 2b: DB-backed project and workspace registry adapters
Add DbProjectRegistry and DbWorkspaceRegistry implementing the existing
ProjectRegistry/WorkspaceRegistry interfaces against Drizzle/PGlite.
Real PGlite contract tests verify full behavioral parity with the
file-backed implementations including FK constraints.

No runtime rewiring — bootstrap and consumers untouched.
2026-03-25 20:39:07 +07:00
Mohamed Boudra 214f85a150 Phase 2a: Drizzle + PGlite database foundation
Add Drizzle schema for all four tables (projects, workspaces,
agent_snapshots, agent_timeline_rows), PGlite bootstrap lifecycle,
migration harness, and real contract tests proving query shapes work.

No runtime authority changes — Session, AgentManager, and existing
storage call sites are untouched. CLI cleanup removes stale projection
fields from two fetchAgentTimeline call sites.

Audit-verified: correctness (all acceptance criteria met), boundary
(no runtime files touched), complexity (minor flags resolved).
2026-03-25 20:39:07 +07:00
Mohamed Boudra acda34fc70 Phase 1d: extract in-memory timeline store 2026-03-25 20:39:07 +07:00
Mohamed Boudra ae4f9e0410 Phase 1c: isolate provider history compatibility seam 2026-03-25 20:39:07 +07:00
Mohamed Boudra b3cdfb203b Phase 1b: extract workspace reconciliation service 2026-03-25 20:39:07 +07:00
Mohamed Boudra 6eb6be77df Phase 1a: collapse snapshot mutation ownership 2026-03-25 20:39:07 +07:00
Mohamed Boudra c202bd5ff7 Phase 0: simplify timeline contract and reconnect behavior 2026-03-25 20:39:07 +07:00
Mohamed Boudra cebde228b1 Add storage revamp implementation plan 2026-03-25 20:39:07 +07:00
84 changed files with 9988 additions and 2295 deletions
+2
View File
@@ -35,6 +35,8 @@ npm run dev # Start daemon + Expo in Tmux
npm run cli -- ls -a -g # List all agents
npm run cli -- daemon status # Check daemon status
npm run typecheck # Always run after changes
npm run db:query # Show DB table row counts
npm run db:query -- "SELECT ..." # Run arbitrary SQL against PGlite
```
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
+18
View File
@@ -35,6 +35,24 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
Check `$PASEO_HOME/daemon.log` for trace-level logs.
### Database queries
Run arbitrary SQL against the PGlite database:
```bash
# Show table row counts
npm run db:query
# Run any SQL
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
# Point at a specific DB directory
npm run db:query -- --db /path/to/db "SELECT ..."
```
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
## Build sync gotchas
### Relay → Daemon
+848
View File
@@ -0,0 +1,848 @@
# Storage Revamp Plan
Status: working implementation plan
This document is meant to guide implementation and review. It captures the decisions that have already converged, names the sequencing and test gates that should not be skipped, and leaves open the product and implementation details that are still legitimately undecided.
## Goals and scope
The storage revamp moves Paseo from a mixed JSON plus in-memory model to an explicit storage layer backed by Drizzle and PGlite.
In scope:
- durable storage for project records
- durable storage for workspace records
- durable storage for agent snapshot metadata
- durable storage for committed agent timeline history
- a simplified timeline contract: committed canonical history vs provisional live updates
- removal of `epoch`/`reset`/projection-heavy timeline recovery paths
- migration and cutover from existing JSON-backed Paseo homes where source data actually exists
## Non-goals
Out of scope for this plan:
- migrating config, keypairs, server identity, or push-token files into the DB
- persisting raw provider deltas, chunk streams, or other transport-only live updates
- designing a remote/cloud database story beyond keeping the schema portable
- committing durable reasoning history in v1 unless product explicitly asks for it later
- preserving legacy timeline semantics such as `epoch`, projection metadata, or provider-history rehydrate on refresh/load
## Current state and migration hazards
Today the server persists three different kinds of state in three different ways:
- projects in `projects/projects.json`
- workspaces in `projects/workspaces.json`
- agent snapshots in `agents/{cwd}/{agent-id}.json`
Committed timeline history is different. It is still effectively an in-memory `AgentManager` concern:
- rows live in `agent.timelineRows`
- sequence numbers are assigned in memory
- retention trimming can drop older rows
- `fetchAgentTimeline` still carries `epoch`, `reset`, `gap`, projection mode, and seq-range metadata to compensate for that model
There are also two migration hazards that should be treated as foundation work, not cleanup:
1. Agent snapshot persistence is split across multiple writers.
`attachAgentStoragePersistence()` flushes `agent_state` snapshots, while `Session` also writes agent records directly for archive/unarchive and related flows. The storage revamp should collapse that into clearer ownership before the DB swap broadens the write surface.
2. Agent load and refresh still depend on provider history hydration.
`Session.ensureAgentLoaded()` resumes or recreates a provider session and then calls `hydrateTimelineFromProvider()`. That makes refresh/load behavior depend on provider-managed history, creates replay and dedup complexity, and blocks a clean storage-owned timeline contract.
The practical implication is that this is not just a database wiring task. The contract and ownership model have to be simplified first.
## Converged decisions and contracts
### Storage choice: Drizzle + PGlite
This plan assumes Drizzle plus PGlite.
Why this is the right default:
- one Postgres-flavored schema and migration pipeline for local and future hosted storage
- no native addon packaging burden in the Electron and local-daemon environment
- Drizzle keeps the schema in TypeScript and fits the existing codebase style better than a heavier codegen-oriented ORM
- PGlite is fast enough for Paseo's local CRUD and timeline query workload
This does not lock the project into every detail of local database lifecycle yet. It does lock the plan onto a Postgres-shaped schema and Drizzle-based migration workflow.
### Storage boundary
The goal is not a grand generic repository framework. The useful seams are:
- `ProjectRegistry`
- `WorkspaceRegistry`
- `AgentSnapshotStore`
- `AgentTimelineStore`
- `WorkspaceReconciliationService`
The refactor should preserve clear domain ownership:
- project and workspace records stay distinct because reconciliation and placement logic are distinct
- agent snapshot metadata stays separate from timeline history
- filesystem reconciliation stays as a service over stored records, not an accidental side effect inside session code
### Timeline contract
The room has converged on a simplified contract:
- `timeline` event with `seq` present means committed canonical history owned by Paseo storage
- `timeline` event with no `seq` means provisional live-only state and is never returned by catch-up fetches
- committed rows must already be in canonical display/history shape, not raw provider chunk shape
- `fetch-after-seq` returns committed rows only
- Phase 0 policy: reconnect does not use a subscribe-time provisional seed
- if a turn is still active after reconnect, the client resumes from future live provisional updates only
- provisional-seed ordering is therefore out of scope for Phase 0 until product explicitly chooses to add that policy
- at most one active provisional assistant stream and one active provisional reasoning stream exist per turn
- tool progress continues to correlate by existing `callId`
- reasoning is live-only for v1 unless product explicitly changes that call later
### Why `epoch` is removed
`epoch` exists today because timeline identity is not fully owned by durable storage. It resets when the in-memory window is invalidated or rebuilt, and clients carry that reset logic in public protocol state.
That is the wrong long-term contract for a DB-backed append-only history. Once committed history is storage-owned and no longer trimmed out from under clients:
- catch-up can be `after seq`
- published sequence numbers do not need to be rewritten
- clients do not need `epoch`, `reset`, or projection-derived recovery metadata
Removing `epoch` is not a cosmetic cleanup. It is the public sign that committed history is finally durable and stable.
### Why refresh/load must not rehydrate provider history
Provider history hydration made sense as a stopgap while Paseo did not own durable timeline history. It is now an obstacle.
Refresh/load must stop rehydrating provider history because it:
- makes the public history contract depend on provider-specific retention and replay behavior
- forces user-message dedup rules and other replay suppression into server logic
- creates ambiguity over whether a timeline row is provider-imported history or Paseo-owned committed history
- makes reconnect behavior depend on re-importing old provider state instead of reading committed rows from storage
The replacement contract is simpler:
- refresh/load restores agent snapshot state from Paseo storage
- committed history comes from `AgentTimelineStore`
- provisional in-flight state, if any, is subscription-scoped and may be seeded once per subscription according to the chosen policy
## Timeline consumption contract
The client model is intentionally two-layered:
- committed history is the durable tail, keyed by monotonically increasing `seq`
- provisional live state is the transient head, carried by unsequenced `timeline` events
The client keeps a committed cursor, not a provisional cursor. Provisional streaming never advances that committed cursor.
### Client mental model
The client stores:
- the last committed `seq` it has accepted for a given agent
- the committed history rows needed for the current view
- any current provisional UI state for in-progress assistant, reasoning, or tool activity
It does not store or reason about a second durable sequence space for provisional data.
### `fetch-after-seq`
`fetch-after-seq(agentId, seq)` means:
- return committed canonical history rows with `seq > cursor`
- never return provisional live state
- never return raw chunk rows
- preserve committed order
If there are no committed rows after that cursor yet, the result is empty even if an in-progress message is still streaming live.
### Subscribe-time provisional seed
A subscribe-time provisional seed, if used by the chosen policy, is a one-time delivery of the current full provisional items for that subscription.
It is not:
- a historical replay of provider deltas
- a second committed history fetch
- a second sequence space
If emitted, it must arrive before later provisional patch events for that subscription.
Phase 0 chooses the simpler policy: no subscribe-time provisional seed for now.
### Reconnect scenarios
Concrete reconnect example:
- the client has accepted committed history through `seq = 120`
- the committed tail includes rows through `120`
- the client UI also shows an in-progress assistant message in the provisional head
- that provisional assistant message has no committed seq yet
Case A: the assistant message finishes while the client is disconnected
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server returns committed row `121`, containing the full finalized assistant message.
4. The client accepts row `121` into the durable tail and advances its committed cursor to `121`.
5. The stale provisional assistant UI from before disconnect is dropped because the committed row has now superseded it.
Case B: the assistant message has not finished by the time the client reconnects
1. The client reconnects with last committed `seq = 120`.
2. The client calls `fetch-after-seq(agentId, 120)`.
3. The server may legitimately return nothing new yet, because committed row `121` does not exist yet.
4. Live streaming resumes.
5. In Phase 0, the client continues from fresh live updates only.
6. When the assistant message finally completes, it becomes one committed row `121`.
In both cases:
- partial streaming before disconnect never advanced the committed cursor
- `fetch-after-seq` is about committed history only
- provisional streaming is transient UI state, not committed history
If the daemon restarted or the agent is unloaded:
- committed history still comes from `AgentTimelineStore`
- the client can fetch committed history by seq without the provider session being active
- provisional state is absent unless a new live subscription later seeds it
### Replacement rule without a second sequence space
The replacement rule is intentionally simple:
- committed rows are authoritative durable history
- provisional UI is best-effort and disposable
- when a committed row arrives that corresponds to previously shown provisional state, the committed row becomes the durable representation and the provisional UI is cleared
This simplicity depends on the agreed invariant that there is at most one active provisional assistant stream and one active provisional reasoning stream per turn.
This avoids introducing `epoch`, a second durable sequence space, or provider-specific replay semantics.
## Authority and ownership
The storage revamp needs explicit authority boundaries so the model cannot drift during implementation.
- `AgentTimelineStore` owns committed timeline authority
- only the committed-history authority path may assign durable `seq`
- provisional/live buffering is owned by the runtime/session path that is already handling live provider events; it is not owned by the DB
- `AgentSnapshotStore` owns durable agent snapshot persistence
- `WorkspaceReconciliationService` owns filesystem-vs-stored-workspace reconciliation
- `ProjectRegistry` and `WorkspaceRegistry` own durable structured records for those domains
- legacy structured-record import is a bootstrap path owned by the structured-record storage layer, not by ad hoc session code
Practical implications:
- providers do not assign committed seqs
- clients do not infer committed seq advancement from provisional events
- session code may read committed history, but it should not become an alternate authority for committed storage semantics
## Interfaces and module boundaries
These are design-level contracts, not implementation-level signatures.
### `AgentSnapshotStore`
Responsibilities:
- read and write durable agent snapshot metadata
- archive and unarchive snapshot records
- list and fetch stored agent snapshots without requiring an active provider session
Useful method shape:
- `get(agentId)`
- `list(filter?)`
- `upsert(snapshotRecord)`
- `archive(agentId, archivedAt)`
- `unarchive(agentId)`
### `AgentTimelineStore`
Responsibilities:
- append finalized committed history rows
- fetch committed history by tail, before-seq, or after-seq queries
- serve committed history for unloaded or restarted agents
- guarantee ordered committed seq allocation per agent
Useful method shape:
- `appendCommitted(agentId, item, metadata?) -> committedRow`
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `getLatestCommittedSeq(agentId)`
Constraints:
- no raw chunk rows in durable history
- no provisional rows in durable history
- seq assignment happens only here or in the narrow authority path directly behind it
### `ProjectRegistry`
Responsibilities:
- store and fetch durable project records
- upsert, archive, and list project records
Useful method shape:
- `get(projectId)`
- `list(filter?)`
- `upsert(projectRecord)`
- `archive(projectId, archivedAt)`
### `WorkspaceRegistry`
Responsibilities:
- store and fetch durable workspace records
- upsert, archive, and list workspace records
Useful method shape:
- `get(workspaceId)`
- `list(filter?)`
- `upsert(workspaceRecord)`
- `archive(workspaceId, archivedAt)`
### `WorkspaceReconciliationService`
Responsibilities:
- own both targeted reconcile-on-register or placement-update checks and broader stale-workspace sweeps
- compare durable workspace records against filesystem reality
- mark stale or missing workspaces according to the chosen policy
- keep reconciliation logic out of session orchestration code
Useful method shape:
- `reconcileTarget(workspaceId | cwd) -> reconcileResult`
- `reconcileSweep() -> reconciliationResult`
### Timeline-facing session/service seam
One session-facing timeline contract is useful so the app/CLI path does not need to know storage internals.
Responsibilities:
- fetch committed history for initial view, older-history pagination, or reconnect
- expose live subscription semantics
- apply the committed-vs-provisional contract consistently for app and CLI consumers
Useful method shape:
- `fetchTail(agentId, limit)`
- `fetchBeforeSeq(agentId, beforeSeq, limit)`
- `fetchAfterSeq(agentId, seq, limit?)`
- `subscribe(agentId, options?)`
This seam is a consumer-facing contract exposed by the session layer so app and CLI callers do not need to know storage internals. It is not a second storage/service authority for committed timeline semantics.
## DB design and query shape
The plan does not need to freeze the final schema, but it should lock the query-driving shape.
### Main tables expected
- `projects`
- `workspaces`
- `agent_snapshots`
- `agent_timeline_rows`
### Fields that matter for core queries
For `agent_timeline_rows`:
- `agent_id`
- committed `seq`
- committed timestamp
- canonical item payload
- optional item kind or type discriminator only if it helps storage ergonomics without expanding the hot-path query surface
For `agent_snapshots`:
- `agent_id`
- provider
- cwd / workspace linkage
- lifecycle/status metadata
- timestamps needed for listing and resume decisions
- archive state
For `projects` and `workspaces`:
- durable IDs
- linkage fields
- display fields
- archive state
- timestamps needed for reconciliation and listing
### Main queries to support efficiently
Hot-path:
- fetch committed tail for an agent
- fetch older committed history before a known seq for scrollback / pagination
- fetch committed rows after a known seq for reconnect/catch-up
- fetch committed history for an unloaded or restarted agent through the same per-agent tail/after-seq path
- append one finalized committed row
- list/fetch agent snapshots for UI and session bootstrap
- list/fetch projects and workspaces for the app shell
Cold-path:
- legacy structured-record import
- archive/unarchive and reconciliation sweeps
### Likely indexes and why
- unique ordered `(agent_id, seq)` constraint/index on timeline rows for per-agent seq uniqueness, ordered append, and after-seq fetch
- index on agent snapshot status/update time for list views
- index on workspace `project_id` for grouped workspace listing
- index on archive state where it materially reduces active-record scans
This is enough to guide implementation without prematurely optimizing secondary filters or analytics-style queries. Per-type filtering is not part of the required v1 hot path.
### Not optimizing yet
The v1 design is intentionally not optimizing for:
- cross-agent global timeline queries
- full-text search over timeline history
- reasoning-history analytics
- provider-specific chunk replay
- multi-tenant or remote-hosted query patterns
## Timeline query scenarios and invariants
Primary timeline queries:
### Fetch tail for initial view
- input: `agentId`, `limit`
- result: latest committed canonical rows only
- invariant: tail fetch does not depend on an active provider session
### Fetch after seq for reconnect/catch-up
- input: `agentId`, last committed `seq`
- result: committed canonical rows with greater seq
- invariant: no provisional rows are returned
- invariant: no gaps or duplicates appear when combined with resumed live streaming
### Fetch before seq for older-history pagination
- input: `agentId`, `beforeSeq`, `limit`
- result: older committed canonical rows with `seq < beforeSeq`
- invariant: older-history pagination uses the same committed-row contract as tail and after-seq reads
- invariant: no provisional rows or raw chunk rows are returned
### Fetch committed history for unloaded or restarted agent
- input: `agentId`
- result: committed history from storage
- invariant: provider-history rehydrate is not required
### Append finalized committed row
- input: finalized logical history item
- result: one committed row with one durable seq
- invariant: no raw chunk rows are written as durable history
Core invariants:
- provisional streaming does not advance the committed seq cursor
- durable history contains finalized canonical rows only
- clients recover from disconnect by fetching committed rows after the last committed seq
- if nothing finalized while disconnected, `fetch-after-seq` may return nothing and live streaming resumes
- if something finalized while disconnected, the returned committed row supersedes stale provisional client UI
- no gaps or duplicates are allowed across catch-up plus live resume
## v1 design non-goals
The design deliberately does not support these in v1:
- durable reasoning history
- historical backfill of timeline data that never existed in Paseo storage
- provider-history rehydrate as a normal refresh/load mechanism
- raw chunk durability
- multiple simultaneous provisional assistant or reasoning streams beyond the agreed single-stream invariant
## Phase plan
Each phase below includes the required goals, sequencing, tests, exit gate, and suggested commit boundary. The phases are intentionally practical rather than over-detailed.
Suggested commit boundaries are approximate. They are there to keep reviewable slices and clear gates, not to force an artificial commit count if two adjacent steps clearly belong together.
### Phase 0: Contract and model cleanup before DB wiring
Goal:
Lock the public and internal timeline contract before introducing a new storage backend.
What changes:
- remove `epoch` from shared timeline cursor/message shapes
- define committed-vs-provisional semantics around `timeline + optional seq`
- remove projection-specific public response baggage such as `reset`, `projection`, seq-range collapse metadata, and related epoch gates
- update server/session/app catch-up logic to use seq-backed committed history plus provisional head seeding
- make reasoning explicitly live-only for v1
- document and enforce the single-provisional-stream invariant per kind
Dependencies:
- none beyond the contract decisions already converged in the room
Tests required:
- shared message parsing tests for the simplified timeline payloads
- app reducer tests for committed append, provisional merge, and reconnect without provisional seeding
- session/WebSocket tests for `live -> disconnect -> fetch-after-seq -> subscribe -> resume`
- explicit no-provisional-seed reconnect test proving that reconnect resumes from committed catch-up plus future live events only
Exit gate:
The client and server can exchange committed and provisional timeline updates without `epoch`, `reset`, or projection metadata, and all timeline catch-up tests pass with the new contract.
Can happen in parallel:
- shared message/schema cleanup
- app reducer and bootstrap policy cleanup
- server session fetch/subscribe cleanup
Must stay sequential:
- final protocol shape must settle before DB schema and timeline store work begin
Suggested commit boundaries:
- protocol and message schema cleanup
- app/session reducer and catch-up logic cleanup
- tests locking the new contract
### Phase 1: Storage boundary refactor with current backends
Goal:
Separate domain ownership and persistence seams while still using the current JSON and in-memory backends.
What changes:
- introduce explicit `AgentSnapshotStore` and `AgentTimelineStore` seams
- keep `ProjectRegistry` and `WorkspaceRegistry` as concrete domain seams rather than wrapping them in a generic repository abstraction
- extract workspace reconciliation into a dedicated `WorkspaceReconciliationService`
- collapse duplicate snapshot-writer paths so snapshot persistence ownership is explicit
- isolate current in-memory timeline logic behind the timeline store interface
- isolate refresh/load dependence on provider history rehydrate behind explicit seams so Phase 4 can remove it cleanly without another structural refactor
Dependencies:
- Phase 0 contract and message-shape cleanup should be settled enough that the extracted seams do not preserve `epoch`-era behavior by accident
Tests required:
- store contract tests for snapshot store and timeline store
- registry contract tests for projects and workspaces
- reconciliation tests using real temp directories and real filesystem mutations
- refresh/load tests proving any remaining provider-history dependency is isolated behind explicit storage/session seams rather than scattered through the runtime
Exit gate:
The server can run unchanged behavior through explicit storage interfaces, duplicate snapshot-writer ownership is collapsed, and any remaining provider-history rehydrate dependency is isolated to a narrow compatibility path that Phase 4 can delete.
Can happen in parallel:
- snapshot-store extraction
- reconciliation extraction
- timeline-store interface extraction
Must stay sequential:
- duplicate snapshot-writer cleanup should land before DB-backed snapshot cutover
- timeline store contract should settle before DB timeline work begins
Suggested commit boundaries:
- snapshot-store ownership cleanup
- reconciliation-service extraction
- timeline-store extraction with in-memory adapter
### Phase 2: DB foundation
Goal:
Land the database runtime, schema, and migration tooling without yet making the DB the source of truth for every domain.
What changes:
- add Drizzle schema and migration workflow
- add PGlite database bootstrap and lifecycle management
- define tables for projects, workspaces, agent snapshots, and committed timeline history
- choose indexes based on the first real query set, especially `agent_id + seq`
- implement DB-backed adapters for the storage seams introduced in Phase 1
Dependencies:
- Phase 1 storage seams should exist before DB adapters become the main implementation path
Tests required:
- migration tests on fresh databases
- migration tests on incremental schema upgrades
- DB adapter tests against a real PGlite database
- startup/shutdown tests for DB bootstrap in the daemon environment
Exit gate:
The repo can create, migrate, and query a real PGlite-backed Drizzle database in tests, and the new adapters satisfy the same contract tests as the current backends.
Suggested commit boundaries:
- Drizzle and PGlite foundation
- schema plus migrations
- DB adapters wired behind existing interfaces
### Phase 3: Structured-record cutover and legacy JSON import
Goal:
Move projects, workspaces, and agent snapshot metadata onto the DB, with explicit import rules from existing Paseo homes.
What changes:
- make the DB-backed project/workspace stores authoritative
- make the DB-backed agent snapshot store authoritative
- import legacy `projects.json`, `workspaces.json`, and agent snapshot JSON into the DB
- run workspace reconciliation against stored records and filesystem state
- keep archive/unarchive and related metadata flows working through the new store ownership model
Dependencies:
- Phase 2 DB bootstrap, migrations, and DB-backed structured-record adapters must be stable first
Tests required:
- import tests from existing JSON files into empty DBs
- idempotent re-import tests where the DB already contains matching rows
- reconciliation tests covering deleted directories and archived-agent edge cases
- agent list/fetch/workspace list session tests against DB-backed stores
Exit gate:
A daemon can start from a legacy Paseo home, import structured records into the DB, restart from the same home again without duplication or state drift, and serve project/workspace/agent metadata without depending on JSON files as the source of truth.
Ordered rollout note:
- first, project/workspace authority plus reconciliation and import harness
- second, restart/idempotence verification on the structured-record path
- third, agent snapshot metadata authority
- structured records should cut over before timeline storage so the timeline phase can rely on stable DB-backed agent identity and metadata
Suggested commit boundaries:
- project/workspace DB cutover plus import harness and reconciliation
- agent snapshot DB cutover and restart/idempotence tests
### Phase 4: Timeline storage and catch-up cutover
Goal:
Move committed timeline history to storage-owned append-only rows and remove provider-history rehydrate from refresh/load.
What changes:
- make `AgentTimelineStore` authoritative for committed history
- write one committed row per finalized history item
- stop storing raw chunk rows as committed history
- keep provisional live updates in memory/subscription scope only
- make fetch/catch-up query committed rows by `agent_id + seq`
- remove provider-history rehydrate from normal refresh/load flows
- remove retention-window timeline trimming and epoch-reset behavior for committed history
Dependencies:
- Phase 0 contract cleanup
- Phase 2 DB foundation
- Phase 3 structured-record cutover, so timeline rows attach to stable DB-backed agent metadata and identity
Tests required:
- timeline store contract tests against real PGlite
- live-to-commit tests for assistant, tool, and reasoning-live-only behavior
- reconnect tests covering committed catch-up plus provisional seed ordering
- migration/cutover tests proving refresh/load do not import provider history
- scenario tests for:
- live stream while connected
- disconnect after committed seq `N`, reconnect, `fetch-after-seq(N)`, then resume live without gaps or duplicates
- daemon restart, then fetch committed history without provider rehydrate
- `attach`/`logs` against an agent with committed stored history but no active provider session
Exit gate:
Committed timeline history survives daemon restart, reconnect from a known committed seq yields ordered history with no gaps or duplicates across catch-up plus live resume, including the provisional-seed case, and refresh/load no longer depend on provider history replay.
Internal sub-gates:
- write-path parity is established before the DB-backed timeline writer becomes authoritative
- `fetch-after-seq` parity is established before legacy committed-history reads are removed
- reconnect no-gap/no-duplicate behavior is proven before timeline cutover is considered complete
- refresh/load de-rehydrate removal lands only after committed-history write and read authority are stable
Can happen in parallel:
- timeline write path
- fetch/catch-up query path
- CLI/app verification against storage-backed committed history behavior
Must stay sequential:
- this phase depends on Phase 0 contract cleanup and Phase 2 DB foundation
- it should land after Phase 3 so agent and workspace identity are already DB-backed
Suggested commit boundaries:
- gated committed timeline store plus write/read parity work, not yet authoritative until matching read-path coverage exists
- authoritative fetch/catch-up cutover together with reconnect scenario coverage
- refresh/load de-rehydrate cleanup and related tests
### Phase 5: Cleanup and removal
Goal:
Delete legacy paths once DB-backed storage and the simplified contract are proven.
What changes:
- remove legacy JSON source-of-truth paths for projects, workspaces, and agent snapshots
- remove in-memory committed timeline ownership and retention trimming
- remove leftover compatibility code and dead reducers from the pre-Phase-0 timeline model
- remove provider-history hydration paths that are no longer part of refresh/load
- update architecture and developer docs to reflect the new ownership model
Dependencies:
- Phase 3 and Phase 4 must both have passed their exit gates
Tests required:
- full server typecheck and existing relevant test suites
- targeted regression tests for deleted legacy branches
- migration tests proving old homes still import correctly into the new model
Exit gate:
There is one durable storage path for structured records and committed timeline history, normal runtime no longer writes legacy JSON or in-memory committed-history branches, and no runtime behavior depends on the removed JSON/in-memory history model.
Suggested commit boundaries:
- legacy path removal
- doc updates and final contract cleanup
## Legacy data and import constraints
The migration story is intentionally narrower than a full historical backfill.
What can be imported from existing Paseo homes:
- projects from `projects/projects.json`
- workspaces from `projects/workspaces.json`
- agent snapshot metadata from agent JSON files
What cannot be broadly backfilled:
- committed timeline history that never existed in Paseo storage
- raw provider history that would need to be rehydrated on refresh/load
- provisional live state from prior daemon lifetimes
That means the honest timeline migration story is:
- existing committed history only exists for whatever Paseo has already written under the new timeline store
- after cutover, timeline history is durable and append-only
- before cutover, old homes do not gain full historical timelines unless a separate import source is defined later
This is a feature, not a gap. It keeps the new contract storage-owned instead of reintroducing provider-specific replay semantics.
## Orchestrator audit plan
Every implementation slice requires an independent audit pass before it is accepted. Audit agents verify; they do not edit files. A phase is not complete until its audit gate passes.
Audit prompts should be checklist-based and evidence-driven. The point of these passes is to catch regressions, hidden behavior changes, missing tests, two-authority drift, and unnecessary complexity before the next phase begins.
### Phase 0 audit
The audit pass must verify:
- the public contract actually removed `epoch`, `reset`, and projection-heavy response baggage where intended
- `timeline` with `seq` vs without `seq` behaves as specified
- the explicit `seq = 120 -> 121` reconnect scenario is covered by tests
- older-history pagination still works under the committed-history contract
- app reducer and session/WebSocket behavior match the agreed contract
### Phase 1 audit
The audit pass must verify:
- no functional behavior changed beyond the intended seam refactor
- duplicate snapshot-writer ownership is actually collapsed
- reconciliation ownership is actually extracted from `Session`
- provider-history dependency is isolated and not still scattered through runtime code
- seam contract tests exist and pass
### Phase 2 audit
The audit pass must verify:
- fresh migrations succeed
- incremental migrations succeed
- DB adapters pass the same contract tests as current backends
- bootstrap and shutdown are stable
- no DB path is prematurely authoritative unless explicitly intended
### Phase 3 audit
The audit pass must verify:
- import from a legacy Paseo home works
- restarting from the same legacy home does not duplicate or drift structured records
- project, workspace, and agent metadata behavior matches prior behavior
- reconciliation still surfaces stale or missing workspaces correctly
- archive, unarchive, list, and fetch flows still work
### Phase 4 audit
The audit pass must verify:
- committed history survives daemon restart
- `fetch-after-seq` and `fetch-before-seq` behave correctly
- no raw chunk rows are persisted
- reconnect satisfies the no-gap/no-duplicate invariant
- the `120 -> 121` reconnect scenario is explicitly verified
- unloaded or restarted agent history reads work without provider rehydrate
- CLI and app history behaviors such as `logs`, `attach`, and timeline fetch still work against committed stored history
### Phase 5 audit
The audit pass must verify:
- normal runtime no longer writes legacy JSON or in-memory committed-history branches
- dead compatibility branches are actually gone
- relevant test suites and typecheck pass
- legacy-home import still works under the final model
## Open questions and risks
Open product or implementation questions:
- should subscribe-time provisional seeding always happen when a turn is active, or only when the client asks for streaming state
- do we want a durable system/error timeline row for failed or canceled turns, or should those remain transport-only
- do any providers require an extra adapter constraint to uphold the single-provisional-stream invariant
Main risks and mitigations:
- risk: the plan quietly recreates projection complexity inside the DB schema
mitigation: store canonical committed rows only, and keep raw/provisional data out of durable history
- risk: refresh/load still leaks provider-history import back into the committed-history path
mitigation: make de-rehydrate behavior a named Phase 4 gate with dedicated tests
- risk: legacy import over-promises timeline backfill
mitigation: keep timeline migration constraints explicit and separate from structured-record import
- risk: the storage refactor turns into a large abstraction exercise
mitigation: keep the seam list concrete and domain-shaped; avoid generic repository frameworks
- risk: sequencing gets inverted and DB work begins before the protocol and ownership model settle
mitigation: keep Phase 0 and Phase 1 as hard prerequisites for DB timeline cutover
+1077
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -37,6 +37,7 @@
"web": "npm run web --workspace=@getpaseo/app",
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
"db:query": "npm run db:query --workspace=@getpaseo/server --",
"cli": "npx tsx packages/cli/src/index.js",
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
+4 -11
View File
@@ -525,9 +525,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
@@ -753,13 +752,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
);
const requestCanonicalCatchUp = useCallback(
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
(agentId: string, cursor: { endSeq: number }) => {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
cursor: { seq: cursor.endSeq },
limit: 0,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
@@ -862,7 +860,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === result.cursor.epoch &&
current.startSeq === result.cursor.startSeq &&
current.endSeq === result.cursor.endSeq
) {
@@ -967,7 +964,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubAgentStream = client.on("agent_stream", (message) => {
if (message.type !== "agent_stream") return;
const { agentId, event, timestamp, seq, epoch } = message.payload;
const { agentId, event, timestamp, seq } = message.payload;
const parsedTimestamp = new Date(timestamp);
const streamEvent = event as AgentStreamEventPayload;
if (
@@ -1009,7 +1006,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const result = processAgentStreamEvent({
event: streamEvent,
seq,
epoch,
currentTail,
currentHead,
currentCursor,
@@ -1033,8 +1029,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (
current &&
typeof seq === "number" &&
typeof epoch === "string" &&
current.epoch === epoch &&
seq >= current.startSeq &&
seq <= current.endSeq
) {
@@ -1043,7 +1037,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
}
if (
current &&
current.epoch === nextCursor.epoch &&
current.startSeq === nextCursor.startSeq &&
current.endSeq === nextCursor.endSeq
) {
@@ -9,13 +9,9 @@ import {
type TimelineCursor,
} from "./session-stream-reducers";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
return {
seqStart: seq,
seq,
provider: "claude",
item: { type, text },
timestamp: new Date(1000 + seq).toISOString(),
@@ -33,22 +29,30 @@ function makeTimelineEvent(
} as AgentStreamEventPayload;
}
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
return {
type: "timeline",
provider: "claude",
item: { type: "user_message", text },
} as AgentStreamEventPayload;
item: {
type: "tool_call",
callId: "call-1",
name: "shell",
status,
detail: {
type: "shell",
command: "pwd",
},
error: null,
},
};
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
direction: "after",
reset: false,
epoch: "epoch-1",
startCursor: null,
endCursor: null,
startSeq: null,
endSeq: null,
entries: [],
error: null,
},
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
const baseStreamInput: ProcessAgentStreamEventInput = {
event: makeTimelineEvent("hello"),
seq: undefined,
epoch: undefined,
currentTail: [],
currentHead: [],
currentCursor: undefined,
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
timestamp: new Date(2000),
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
describe("processTimelineResponse", () => {
it("returns error path when payload.error is set", () => {
const result = processTimelineResponse({
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.head).toBe(baseTimelineInput.currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("returns error with no init resolution when no deferred exists", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
payload: {
...baseTimelineInput.payload,
error: "timeout",
},
});
expect(result.error).toBe("timeout");
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(true);
});
it("replaces tail and clears head when reset=true", () => {
const existingTail: StreamItem[] = [
{
kind: "user_message",
id: "old",
text: "old message",
timestamp: new Date(500),
},
];
const existingHead: StreamItem[] = [
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
const provisionalHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-1",
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentTail: existingTail,
currentHead: existingHead,
payload: {
...baseTimelineInput.payload,
reset: true,
startCursor: { seq: 1 },
endCursor: { seq: 3 },
entries: [
makeTimelineEntry(1, "first"),
makeTimelineEntry(2, "second"),
makeTimelineEntry(3, "third"),
],
},
});
expect(result.tail).not.toBe(existingTail);
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
});
expect(result.error).toBe(null);
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("sets cursor to null when reset=true but no cursors in payload", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
payload: {
...baseTimelineInput.payload,
reset: true,
entries: [],
},
});
expect(result.cursor).toBe(null);
expect(result.cursorChanged).toBe(true);
});
it("performs bootstrap tail init with catch-up side effect", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentHead: provisionalHead,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "tail",
payload: {
...baseTimelineInput.payload,
direction: "tail",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 5 },
startSeq: 1,
endSeq: 5,
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
},
});
// Bootstrap tail replaces
expect(result.tail.length).toBeGreaterThan(0);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
// Should have catch-up side effect
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 5,
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
expect(catchUp).toEqual({
type: "catch_up",
cursor: { endSeq: 5 },
});
});
it("appends incrementally for contiguous seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
it("prepends older committed history for before pagination", () => {
const currentTail: StreamItem[] = [
{
kind: "assistant_message",
id: "tail-3",
text: "newer",
timestamp: new Date(3000),
},
});
expect(result.tail.length).toBeGreaterThan(0);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
expect(result.error).toBe(null);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 3,
};
];
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(10, "far ahead")],
},
});
// Gap should trigger catch-up
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 3,
});
});
it("drops stale entries silently", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
},
});
// No new items appended (all dropped as stale)
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("drops entries with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-2",
entries: [makeTimelineEntry(6, "different epoch")],
},
});
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.cursorChanged).toBe(false);
});
it("resolves init when deferred matches direction", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.initResolution).toBe("resolve");
expect(result.clearInitializing).toBe(true);
});
it("does not resolve init when directions differ (before vs after)", () => {
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: true,
initRequestDirection: "after",
currentTail,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
entries: [],
startSeq: 1,
endSeq: 2,
entries: [
makeTimelineEntry(1, "hello", "user_message"),
makeTimelineEntry(2, "older"),
],
},
});
// "before" direction doesn't match "after" initRequestDirection,
// and "before" is not a bootstrap tail path, so init should NOT resolve
expect(result.initResolution).toBe(null);
expect(result.clearInitializing).toBe(false);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
startSeq: 1,
endSeq: 4,
});
expect(result.tail).toHaveLength(3);
expect(result.tail[0]?.kind).toBe("user_message");
expect(result.tail[1]?.kind).toBe("assistant_message");
expect(result.tail[2]).toBe(currentTail[0]);
});
it("clears initializing even without deferred", () => {
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
isInitializing: true,
hasActiveInitDeferred: false,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
entries: [],
},
});
expect(result.clearInitializing).toBe(true);
expect(result.initResolution).toBe(null);
});
it("always includes flush_pending_updates side effect on success", () => {
const result = processTimelineResponse({
...baseTimelineInput,
payload: {
...baseTimelineInput.payload,
entries: [],
},
});
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
});
it("initializes cursor when no existing cursor on first entries", () => {
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor: undefined,
payload: {
...baseTimelineInput.payload,
epoch: "epoch-1",
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
startSeq: 121,
endSeq: 121,
entries: [makeTimelineEntry(121, "finalized reply")],
},
});
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 2,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
});
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "still streaming",
timestamp: new Date(120000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentHead,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: null,
endSeq: null,
entries: [],
},
});
expect(result.head).toBe(currentHead);
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
});
it("requests catch-up when committed rows arrive with a forward gap", () => {
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processTimelineResponse({
...baseTimelineInput,
currentCursor,
payload: {
...baseTimelineInput.payload,
direction: "after",
startSeq: 125,
endSeq: 125,
entries: [makeTimelineEntry(125, "far ahead")],
},
});
expect(result.cursorChanged).toBe(false);
expect(result.tail).toBe(baseTimelineInput.currentTail);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
});
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
describe("processAgentStreamEvent", () => {
it("passes through non-timeline events without cursor changes", () => {
const turnEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
it("treats seq-less timeline events as provisional head updates", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnEvent,
event: makeTimelineEvent("partial"),
seq: undefined,
epoch: undefined,
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.head).toHaveLength(1);
expect(result.head[0]).toMatchObject({
kind: "assistant_message",
text: "partial",
});
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBe(null);
expect(result.sideEffects).toEqual([]);
});
it("accepts timeline event with cursor advance", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
const currentHead: StreamItem[] = [
{
kind: "assistant_message",
id: "head-assistant",
text: "partial",
timestamp: new Date(1000),
},
];
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("new chunk"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
event: makeTimelineEvent("finalized reply"),
seq: 121,
currentHead,
currentCursor,
});
expect(result.changedTail).toBe(true);
expect(result.changedHead).toBe(true);
expect(result.head).toEqual([]);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
endSeq: 121,
});
expect(result.tail[result.tail.length - 1]).toMatchObject({
kind: "assistant_message",
text: "finalized reply",
});
expect(result.sideEffects).toEqual([]);
});
it("detects gap and emits catch-up side effect", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 4,
};
it("replaces provisional tool progress when the committed tool row arrives", () => {
const provisional = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("running"),
seq: undefined,
});
const committed = processAgentStreamEvent({
...baseStreamInput,
event: makeToolCallEvent("completed"),
seq: 8,
currentHead: provisional.head,
currentTail: provisional.tail,
currentCursor: { startSeq: 1, endSeq: 7 },
});
expect(committed.head).toEqual([]);
expect(committed.tail).toHaveLength(1);
expect(committed.tail[0]).toMatchObject({
kind: "tool_call",
payload: {
source: "agent",
data: {
callId: "call-1",
status: "completed",
},
},
});
});
it("requests catch-up when a committed live row skips ahead", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("far ahead"),
seq: 10,
epoch: "epoch-1",
currentCursor: existingCursor,
seq: 125,
currentCursor: { startSeq: 1, endSeq: 120 },
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
expect(catchUp).toBeDefined();
expect(catchUp!.cursor).toEqual({
epoch: "epoch-1",
endSeq: 4,
});
});
it("drops stale timeline event", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 8,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("old"),
seq: 5,
epoch: "epoch-1",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.sideEffects).toContainEqual({
type: "catch_up",
cursor: { endSeq: 120 },
});
});
it("clears provisional head on terminal turn events without committing it to tail", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: {
type: "turn_completed",
provider: "claude",
},
currentHead: [
{
kind: "thought",
id: "reasoning-1",
text: "thinking",
timestamp: new Date(1000),
status: "loading",
},
],
});
expect(result.changedHead).toBe(true);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("drops timeline event with epoch mismatch", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("wrong epoch"),
seq: 6,
epoch: "epoch-2",
currentCursor: existingCursor,
});
expect(result.cursorChanged).toBe(false);
expect(result.changedTail).toBe(false);
expect(result.changedHead).toBe(false);
expect(result.sideEffects).toEqual([]);
});
it("initializes cursor when none exists", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("first"),
seq: 1,
epoch: "epoch-1",
currentCursor: undefined,
});
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 1,
});
});
it("derives optimistic idle status on turn_completed for running agent", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent).not.toBe(null);
expect(result.agent!.status).toBe("idle");
expect(result.agent!.updatedAt.getTime()).toBe(2000);
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
});
it("derives optimistic error status on turn_failed for running agent", () => {
const turnFailedEvent: AgentStreamEventPayload = {
type: "turn_failed",
provider: "claude",
error: "something broke",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnFailedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.status).toBe("error");
});
it("does not change agent when status is not running", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "idle",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("does not change agent when no agent is provided", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: null,
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
});
it("preserves updatedAt when agent timestamp is newer than event", () => {
const turnCompletedEvent: AgentStreamEventPayload = {
type: "turn_completed",
provider: "claude",
};
const result = processAgentStreamEvent({
...baseStreamInput,
event: turnCompletedEvent,
currentAgent: {
status: "running",
updatedAt: new Date(5000),
lastActivityAt: new Date(5000),
},
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(true);
expect(result.agent!.updatedAt.getTime()).toBe(5000);
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
});
it("does not produce agent patch for non-terminal events", () => {
const result = processAgentStreamEvent({
...baseStreamInput,
event: makeTimelineEvent("just text"),
currentAgent: {
status: "running",
updatedAt: new Date(1000),
lastActivityAt: new Date(1000),
},
seq: 1,
epoch: "epoch-1",
timestamp: new Date(2000),
});
expect(result.agentChanged).toBe(false);
expect(result.agent).toBe(null);
expect(result.head).toEqual([]);
expect(result.tail).toEqual([]);
});
});
@@ -1,7 +1,7 @@
import type { AgentStreamEventPayload } from "@server/shared/messages";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
import type { StreamItem } from "@/types/stream";
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
import {
classifySessionTimelineSeq,
type SessionTimelineSeqDecision,
@@ -12,38 +12,25 @@ import {
} from "@/contexts/session-timeline-bootstrap-policy";
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
// ---------------------------------------------------------------------------
// Shared cursor type
// ---------------------------------------------------------------------------
export type TimelineCursor = {
epoch: string;
startSeq: number;
endSeq: number;
};
// ---------------------------------------------------------------------------
// Side-effect discriminated unions
// ---------------------------------------------------------------------------
export type TimelineReducerSideEffect =
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
| { type: "catch_up"; cursor: { endSeq: number } }
| { type: "flush_pending_updates" };
export type AgentStreamReducerSideEffect = {
type: "catch_up";
cursor: { epoch: string; endSeq: number };
cursor: { endSeq: number };
};
// ---------------------------------------------------------------------------
// processTimelineResponse
// ---------------------------------------------------------------------------
type TimelineDirection = "tail" | "before" | "after";
type InitRequestDirection = "tail" | "after";
type TimelineResponseEntry = {
seqStart: number;
seq: number;
provider: string;
item: Record<string, unknown>;
timestamp: string;
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
payload: {
agentId: string;
direction: TimelineDirection;
reset: boolean;
epoch: string;
startCursor: { seq: number } | null;
endCursor: { seq: number } | null;
startSeq: number | null;
endSeq: number | null;
entries: TimelineResponseEntry[];
error: string | null;
};
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
sideEffects: TimelineReducerSideEffect[];
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
// ------------------------------------------------------------------
// Error path: reject init and leave stream state unchanged
// ------------------------------------------------------------------
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
// ------------------------------------------------------------------
// Convert entries to timeline units
// ------------------------------------------------------------------
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seqStart,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const toHydratedEvents = (
units: typeof timelineUnits,
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
units.map(({ event, timestamp }) => ({ event, timestamp }));
// ------------------------------------------------------------------
// Derive bootstrap policy (replace vs incremental)
// ------------------------------------------------------------------
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
reset: payload.reset,
epoch: payload.epoch,
endCursor: payload.endCursor,
isInitializing,
hasActiveInitDeferred,
});
const replace = bootstrapPolicy.replace;
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (replace) {
// ----------------------------------------------------------------
// Replace path: full hydration from scratch
// ----------------------------------------------------------------
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
source: "canonical",
});
nextHead = [];
if (payload.startCursor && payload.endCursor) {
nextCursor = {
epoch: payload.epoch,
startSeq: payload.startCursor.seq,
endSeq: payload.endCursor.seq,
};
cursorChanged = true;
} else {
nextCursor = null;
cursorChanged = true;
}
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (timelineUnits.length > 0) {
// ----------------------------------------------------------------
// Incremental append path
// ----------------------------------------------------------------
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { epoch: string; endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
epoch: payload.epoch,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale" || decision === "drop_epoch") {
continue;
}
acceptedUnits.push(unit);
if (decision === "init") {
cursor = {
epoch: payload.epoch,
startSeq: unit.seq,
endSeq: unit.seq,
};
continue;
}
if (!cursor) {
continue;
}
cursor = {
...cursor,
endSeq: unit.seq,
};
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (
cursor &&
(!currentCursor ||
currentCursor.epoch !== cursor.epoch ||
currentCursor.startSeq !== cursor.startSeq ||
currentCursor.endSeq !== cursor.endSeq)
) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
// ------------------------------------------------------------------
// Flush pending agent updates side effect
// ------------------------------------------------------------------
sideEffects.push({ type: "flush_pending_updates" });
// ------------------------------------------------------------------
// Init resolution
// ------------------------------------------------------------------
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
reset: payload.reset,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution,
clearInitializing,
error: null,
sideEffects,
};
}
// ---------------------------------------------------------------------------
// processAgentStreamEvent
// ---------------------------------------------------------------------------
export interface ProcessAgentStreamEventInput {
event: AgentStreamEventPayload;
seq: number | undefined;
epoch: string | undefined;
currentTail: StreamItem[];
currentHead: StreamItem[];
currentCursor: TimelineCursor | undefined;
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
sideEffects: AgentStreamReducerSideEffect[];
}
function cursorsEqual(
left: TimelineCursor | null | undefined,
right: TimelineCursor | null | undefined,
): boolean {
if (!left || !right) {
return left === right;
}
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
}
function removeSupersededProvisionalItems(
head: StreamItem[],
event: AgentStreamEventPayload,
): StreamItem[] {
if (head.length === 0 || event.type !== "timeline") {
return head;
}
let nextHead = head;
if (event.item.type === "assistant_message") {
nextHead = head.filter((item) => item.kind !== "assistant_message");
} else if (event.item.type === "tool_call") {
const committedToolCall = event.item;
nextHead = head.filter(
(item) =>
item.kind !== "tool_call" ||
item.payload.source !== "agent" ||
item.payload.data.callId !== committedToolCall.callId,
);
}
return nextHead.length === head.length ? head : nextHead;
}
export function processTimelineResponse(
input: ProcessTimelineResponseInput,
): ProcessTimelineResponseOutput {
const {
payload,
currentTail,
currentHead,
currentCursor,
isInitializing,
hasActiveInitDeferred,
initRequestDirection,
} = input;
if (payload.error) {
return {
tail: currentTail,
head: currentHead,
cursor: currentCursor,
cursorChanged: false,
initResolution: hasActiveInitDeferred ? "reject" : null,
clearInitializing: isInitializing,
error: payload.error,
sideEffects: [],
};
}
const timelineUnits = payload.entries.map((entry) => ({
seq: entry.seq,
event: {
type: "timeline",
provider: entry.provider,
item: entry.item,
} as AgentStreamEventPayload,
timestamp: new Date(entry.timestamp),
}));
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
direction: payload.direction,
endSeq: payload.endSeq,
isInitializing,
hasActiveInitDeferred,
});
let nextTail = currentTail;
let nextHead = currentHead;
let nextCursor: TimelineCursor | null | undefined = currentCursor;
let cursorChanged = false;
const sideEffects: TimelineReducerSideEffect[] = [];
if (bootstrapPolicy.replace) {
nextTail = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextHead = [];
nextCursor =
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: payload.endSeq,
}
: null;
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
if (bootstrapPolicy.catchUpCursor) {
sideEffects.push({
type: "catch_up",
cursor: bootstrapPolicy.catchUpCursor,
});
}
} else if (payload.direction === "before") {
const prepended = hydrateStreamState(
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
const derivedCursor =
typeof payload.startSeq === "number"
? {
startSeq: payload.startSeq,
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
}
: currentCursor;
nextCursor = derivedCursor;
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
} else if (timelineUnits.length > 0) {
const acceptedUnits: typeof timelineUnits = [];
let cursor = currentCursor;
let gapCursor: { endSeq: number } | null = null;
for (const unit of timelineUnits) {
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
cursor: cursor ? { endSeq: cursor.endSeq } : null,
seq: unit.seq,
});
if (decision === "gap") {
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
break;
}
if (decision === "drop_stale") {
continue;
}
acceptedUnits.push(unit);
cursor =
decision === "init"
? { startSeq: unit.seq, endSeq: unit.seq }
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
}
if (acceptedUnits.length > 0) {
nextTail = acceptedUnits.reduce<StreamItem[]>(
(state, { event, timestamp }) =>
reduceStreamUpdate(state, event, timestamp, {
source: "canonical",
}),
currentTail,
);
}
if (cursor && !cursorsEqual(currentCursor, cursor)) {
nextCursor = cursor;
cursorChanged = true;
}
if (gapCursor) {
sideEffects.push({ type: "catch_up", cursor: gapCursor });
}
}
sideEffects.push({ type: "flush_pending_updates" });
const shouldResolveDeferredInit = shouldResolveTimelineInit({
hasActiveInitDeferred,
isInitializing,
initRequestDirection,
responseDirection: payload.direction,
});
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
return {
tail: nextTail,
head: nextHead,
cursor: nextCursor,
cursorChanged,
initResolution: shouldResolveDeferredInit ? "resolve" : null,
clearInitializing,
error: null,
sideEffects,
};
}
export function processAgentStreamEvent(
input: ProcessAgentStreamEventInput,
): ProcessAgentStreamEventOutput {
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
input;
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
let shouldApplyStreamEvent = true;
let nextTail = currentTail;
let nextHead = currentHead;
let changedTail = false;
let changedHead = false;
let nextTimelineCursor: TimelineCursor | null = null;
let cursorChanged = false;
const sideEffects: AgentStreamReducerSideEffect[] = [];
// ------------------------------------------------------------------
// Timeline sequencing gate
// ------------------------------------------------------------------
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
if (event.type === "timeline" && typeof seq === "number") {
const decision = classifySessionTimelineSeq({
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
epoch,
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
seq,
});
if (decision === "init") {
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
cursorChanged = true;
} else if (decision === "accept") {
nextTimelineCursor = {
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
epoch,
endSeq: seq,
};
cursorChanged = true;
} else if (decision === "gap") {
shouldApplyStreamEvent = false;
if (decision === "gap") {
if (currentCursor) {
sideEffects.push({
type: "catch_up",
cursor: {
epoch: currentCursor.epoch,
endSeq: currentCursor.endSeq,
},
cursor: { endSeq: currentCursor.endSeq },
});
}
} else {
// drop_stale or drop_epoch
shouldApplyStreamEvent = false;
} else if (decision !== "drop_stale") {
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
source: "canonical",
});
changedTail = nextTail !== currentTail;
nextHead = removeSupersededProvisionalItems(currentHead, event);
changedHead = nextHead !== currentHead;
nextTimelineCursor =
decision === "init"
? { startSeq: seq, endSeq: seq }
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
}
} else if (event.type === "timeline") {
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
source: "live",
});
changedHead = nextHead !== currentHead;
} else if (
(event.type === "turn_completed" ||
event.type === "turn_canceled" ||
event.type === "turn_failed") &&
currentHead.length > 0
) {
nextHead = [];
changedHead = true;
}
// ------------------------------------------------------------------
// Apply stream event to tail/head
// ------------------------------------------------------------------
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
? applyStreamEvent({
tail: currentTail,
head: currentHead,
event,
timestamp,
source: "live",
})
: {
tail: currentTail,
head: currentHead,
changedTail: false,
changedHead: false,
};
// ------------------------------------------------------------------
// Optimistic lifecycle status
// ------------------------------------------------------------------
let agentPatch: AgentPatch | null = null;
let agentChanged = false;
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
}
return {
tail,
head,
tail: nextTail,
head: nextHead,
changedTail,
changedHead,
cursor: nextTimelineCursor,
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
import {
deriveBootstrapTailTimelinePolicy,
deriveInitialTimelineRequest,
shouldResolveTimelineInit,
} from "./session-timeline-bootstrap-policy";
describe("deriveBootstrapTailTimelinePolicy", () => {
it("always replaces on explicit reset without catch-up cursor", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "after",
reset: true,
epoch: "epoch-1",
endCursor: { seq: 200 },
isInitializing: false,
hasActiveInitDeferred: false,
describe("deriveInitialTimelineRequest", () => {
it("uses tail bootstrap when history has not synced yet", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "tail",
limit: 200,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toBeNull();
});
it("uses catch-up after the committed cursor once history is synced", () => {
expect(
deriveInitialTimelineRequest({
cursor: { seq: 42 },
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "after",
cursor: { seq: 42 },
limit: 0,
});
});
});
describe("deriveBootstrapTailTimelinePolicy", () => {
it("forces baseline replace and canonical catch-up for init tail race", () => {
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
const advancedCursor = { endSeq: 205 };
const tailSeqStart = 101;
const tailSeqEnd = 200;
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
const decision = classifySessionTimelineSeq({
cursor: advancedCursor,
epoch: "epoch-1",
seq,
});
if (decision === "accept" || decision === "init") {
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: true,
hasActiveInitDeferred: true,
});
expect(policy.replace).toBe(true);
expect(policy.catchUpCursor).toEqual({
epoch: "epoch-1",
endSeq: 200,
});
});
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
it("does not replace non-bootstrap, non-reset responses", () => {
const policy = deriveBootstrapTailTimelinePolicy({
direction: "tail",
reset: false,
epoch: "epoch-1",
endCursor: { seq: 200 },
endSeq: 200,
isInitializing: false,
hasActiveInitDeferred: false,
});
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "tail",
reset: false,
}),
).toBe(true);
});
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "tail",
responseDirection: "after",
reset: false,
}),
).toBe(false);
});
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
isInitializing: true,
initRequestDirection: "after",
responseDirection: "after",
reset: false,
}),
).toBe(true);
});
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
} | null;
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
cursor?: { seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
cursor: { seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
export function deriveBootstrapTailTimelinePolicy({
direction,
reset,
epoch,
endCursor,
endSeq,
isInitializing,
hasActiveInitDeferred,
}: {
direction: TimelineDirection;
reset: boolean;
epoch: string;
endCursor: BootstrapTailCursor;
endSeq: number | null;
isInitializing: boolean;
hasActiveInitDeferred: boolean;
}): {
replace: boolean;
catchUpCursor: { epoch: string; endSeq: number } | null;
catchUpCursor: { endSeq: number } | null;
} {
if (reset) {
return { replace: true, catchUpCursor: null };
}
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
if (!isBootstrapTailInit) {
return { replace: false, catchUpCursor: null };
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
return {
replace: true,
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
};
}
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
isInitializing,
initRequestDirection,
responseDirection,
reset,
}: {
hasActiveInitDeferred: boolean;
isInitializing: boolean;
initRequestDirection: InitRequestDirection;
responseDirection: TimelineDirection;
reset: boolean;
}): boolean {
if (!hasActiveInitDeferred || !isInitializing) {
return false;
}
if (reset) {
return true;
}
return responseDirection === initRequestDirection;
}
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
it("accepts contiguous forward seq", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 5,
}),
).toBe("accept");
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
it("drops stale seq older than the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 7,
}),
).toBe("drop_stale");
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
it("drops duplicate replay seq equal to the current end", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 8 },
epoch: "epoch-1",
cursor: { endSeq: 8 },
seq: 8,
}),
).toBe("drop_stale");
});
it("drops epoch mismatch", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-2",
seq: 5,
}),
).toBe("drop_epoch");
});
it("initializes when cursor is null", () => {
expect(
classifySessionTimelineSeq({
cursor: null,
epoch: "epoch-1",
seq: 1,
}),
).toBe("init");
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
it("classifies forward gaps", () => {
expect(
classifySessionTimelineSeq({
cursor: { epoch: "epoch-1", endSeq: 4 },
epoch: "epoch-1",
cursor: { endSeq: 4 },
seq: 9,
}),
).toBe("gap");
@@ -1,28 +1,22 @@
export type SessionTimelineSeqCursor =
| {
epoch: string;
endSeq: number;
}
| null
| undefined;
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
export function classifySessionTimelineSeq({
cursor,
epoch,
seq,
}: {
cursor: SessionTimelineSeqCursor;
epoch: string;
seq: number;
}): SessionTimelineSeqDecision {
if (!cursor) {
return "init";
}
if (cursor.epoch !== epoch) {
return "drop_epoch";
}
if (seq <= cursor.endSeq) {
return "drop_stale";
}
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
it("uses committed tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical tail bootstrap when cursor is missing", () => {
it("uses committed tail bootstrap when cursor is missing", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 200,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor once history is synced", () => {
it("uses committed catch-up after the current cursor once history is synced", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: true,
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
cursor: { seq: 42 },
limit: 0,
projection: "canonical",
});
});
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
@@ -60,7 +60,7 @@ export function useAgentInitialization({
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
cursor: cursor ? { seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
@@ -107,7 +107,6 @@ export function useAgentInitialization({
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
});
} catch (error) {
setAgentInitializing(agentId, false);
-1
View File
@@ -191,7 +191,6 @@ export type DaemonServerInfo = {
};
export interface AgentTimelineCursorState {
epoch: string;
startSeq: number;
endSeq: number;
}
-1
View File
@@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: {
try {
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
direction: "tail",
projection: "projected",
limit: 200,
});
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
-1
View File
@@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems(
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
direction: "tail",
limit: 0,
projection: "projected",
});
return timeline.entries.map((entry) => entry.item);
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./packages/server/src/server/db/schema.ts",
out: "./packages/server/src/server/db/migrations",
dialect: "postgresql",
strict: true,
verbose: true,
});
+4
View File
@@ -46,6 +46,7 @@
"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",
@@ -64,6 +65,7 @@
"@ai-sdk/openai": "2.0.52",
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
"@deepgram/sdk": "^3.4.0",
"@electric-sql/pglite": "^0.4.1",
"@getpaseo/highlight": "0.1.33",
"@getpaseo/relay": "0.1.33",
"@modelcontextprotocol/sdk": "^1.20.1",
@@ -73,6 +75,7 @@
"ai": "5.0.78",
"ajv": "^8.17.1",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"fast-uri": "^3.1.0",
@@ -96,6 +99,7 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"drizzle-kit": "^0.31.10",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env npx tsx
/**
* Run arbitrary SQL against the running Paseo daemon's PGlite database.
*
* Connects to the daemon's dev query endpoint (available in dev mode only).
*
* Usage:
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
* npx tsx packages/server/scripts/db-query.ts --port 6767 "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";
function findDaemonPort(explicit?: string): number {
if (explicit) return parseInt(explicit, 10);
// Check for running dev daemon config
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 listen = config.daemon?.listen;
if (typeof listen === "string") {
const portMatch = listen.match(/:(\d+)$/);
if (portMatch) return parseInt(portMatch[1]!, 10);
}
} catch {}
}
}
}
return 6767;
}
async function main() {
const args = process.argv.slice(2);
let port: string | undefined;
const queries: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--port" && args[i + 1]) {
port = args[++i];
} else {
queries.push(args[i]!);
}
}
if (queries.length === 0) {
queries.push(
"SELECT 'agent_snapshots' AS table_name, count(*)::int AS rows FROM agent_snapshots UNION ALL " +
"SELECT 'agent_timeline_rows', count(*)::int FROM agent_timeline_rows UNION ALL " +
"SELECT 'projects', count(*)::int FROM projects UNION ALL " +
"SELECT 'workspaces', count(*)::int FROM workspaces " +
"ORDER BY table_name",
);
}
const daemonPort = findDaemonPort(port);
const url = `http://localhost:${daemonPort}/dev/db-query`;
for (const sql of queries) {
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql }),
});
if (!res.ok) {
const text = await res.text();
console.error(`HTTP ${res.status}: ${text}`);
continue;
}
const { rows } = await res.json();
if (rows.length === 0) {
console.log("(0 rows)\n");
} else {
console.table(rows);
}
} catch (err: any) {
if (err.cause?.code === "ECONNREFUSED") {
console.error(`Cannot connect to daemon at port ${daemonPort}. Is it running?`);
process.exit(1);
}
console.error(`Error: ${err.message}\n`);
}
}
}
main();
@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_snapshot",
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
agentId: "agent_cli",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 1 },
startSeq: 1,
endSeq: 1,
hasOlder: false,
hasNewer: false,
entries: [
{
timestamp: "2026-02-08T20:20:00.000Z",
provider: "codex",
seqStart: 1,
seqEnd: 1,
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
collapsed: [],
seq: 1,
item: {
type: "tool_call",
callId: "call_cli_invalid",
+3 -6
View File
@@ -130,7 +130,6 @@ export type DaemonEvent =
event: AgentStreamEventPayload;
timestamp: string;
seq?: number;
epoch?: string;
}
| { type: "status"; payload: { status: string } & Record<string, unknown> }
| { type: "agent_deleted"; agentId: string }
@@ -244,13 +243,13 @@ type KillTerminalPayload = KillTerminalResponse["payload"];
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
export type FetchAgentTimelineCursor = NonNullable<
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
>;
export type FetchAgentTimelineOptions = {
direction?: FetchAgentTimelineDirection;
cursor?: FetchAgentTimelineCursor;
limit?: number;
projection?: FetchAgentTimelineProjection;
requestId?: string;
};
@@ -1396,7 +1395,6 @@ export class DaemonClient {
...(options.direction ? { direction: options.direction } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.projection ? { projection: options.projection } : {}),
});
const payload = await this.sendRequest({
@@ -3100,7 +3098,6 @@ export class DaemonClient {
event: msg.payload.event,
timestamp: msg.payload.timestamp,
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
};
case "status":
return { type: "status", payload: msg.payload };
@@ -0,0 +1,128 @@
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 {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
export type AgentLoadingServiceOptions = {
agentManager: Pick<
AgentManager,
| "createAgent"
| "getAgent"
| "reloadAgentSession"
| "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentSnapshotStore, "get">;
logger: pino.Logger;
};
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
export class AgentLoadingService {
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
private readonly logger: pino.Logger;
constructor(options: AgentLoadingServiceOptions) {
this.agentManager = options.agentManager;
this.agentStorage = options.agentStorage;
this.logger = options.logger.child({ component: "agent-loading" });
}
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
if (inflight) {
return inflight;
}
const initPromise = this.loadStoredAgent(options);
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentBootstrapLoads.get(options.agentId);
if (current === initPromise) {
pendingAgentBootstrapLoads.delete(options.agentId);
}
}
}
async resumeAgent(options: {
handle: AgentPersistenceHandle;
overrides?: Partial<AgentSessionConfig>;
}): Promise<ManagedAgent> {
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
}
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(options.agentId);
if (existing) {
return existing.persistence
? await this.agentManager.reloadAgentSession(options.agentId)
: existing;
}
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
if (!handle) {
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
}
return this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
}
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
const record = await this.agentStorage.get(options.agentId);
if (!record) {
throw new Error(`Agent not found: ${options.agentId}`);
}
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
options.agentId,
extractTimestamps(record),
);
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
labels: record.labels,
});
this.logger.info(
{ agentId: options.agentId, provider: record.provider },
"Agent created from stored config",
);
}
return this.agentManager.getAgent(options.agentId) ?? snapshot;
}
}
@@ -37,7 +37,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
logger: Logger;
@@ -178,7 +178,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -192,7 +192,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
import type { ManagedAgent } from "./agent-manager.js";
import type { StoredAgentRecord } from "./agent-storage.js";
export interface AgentSnapshotStore {
list(): Promise<StoredAgentRecord[]>;
get(agentId: string): Promise<StoredAgentRecord | null>;
upsert(record: StoredAgentRecord): Promise<void>;
remove(agentId: string): Promise<void>;
applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
setTitle(agentId: string, title: string): Promise<void>;
}
@@ -7,6 +7,7 @@ import type { Logger } from "pino";
import { 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
@@ -69,8 +70,11 @@ export type SerializableAgentConfig = Pick<
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value);
}
export class AgentStorage {
export class AgentStorage implements AgentSnapshotStore {
private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
@@ -300,7 +304,7 @@ export class AgentStorage {
try {
const content = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(content);
return STORED_AGENT_SCHEMA.parse(parsed);
return parseStoredAgentRecord(parsed);
} catch (error) {
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
return null;
@@ -0,0 +1,60 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
export type AgentTimelineRow = {
seq: number;
timestamp: string;
item: AgentTimelineItem;
};
export type AgentTimelineCursor = {
seq: number;
};
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
export type AgentTimelineFetchOptions = {
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
* Number of canonical rows to return.
* - undefined: store default
* - 0: all rows in the selected window
*/
limit?: number;
};
export type AgentTimelineWindow = {
minSeq: number;
maxSeq: number;
nextSeq: number;
};
export type AgentTimelineFetchResult = {
direction: AgentTimelineFetchDirection;
window: AgentTimelineWindow;
hasOlder: boolean;
hasNewer: boolean;
rows: AgentTimelineRow[];
};
export interface AgentTimelineStore {
appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<AgentTimelineRow>;
fetchCommitted(
agentId: string,
options?: AgentTimelineFetchOptions,
): Promise<AgentTimelineFetchResult>;
getLatestCommittedSeq(agentId: string): Promise<number>;
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
getLastAssistantMessage(agentId: string): Promise<string | null>;
hasCommittedUserMessage(
agentId: string,
options: { messageId: string; text: string },
): Promise<boolean>;
deleteAgent(agentId: string): Promise<void>;
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
}
@@ -0,0 +1,244 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
} from "./agent-timeline-store-types.js";
export type SeedAgentTimelineOptions = {
items?: readonly AgentTimelineItem[];
rows?: readonly AgentTimelineRow[];
nextSeq?: number;
timestamp?: string;
};
type AgentTimelineState = {
rows: AgentTimelineRow[];
nextSeq: number;
};
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
return { ...row };
}
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
export class InMemoryAgentTimelineStore {
private readonly states = new Map<string, AgentTimelineState>();
has(agentId: string): boolean {
return this.states.has(agentId);
}
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
const timestamp = options?.timestamp ?? new Date().toISOString();
const rows = options?.rows?.length
? options.rows.map(cloneRow)
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
const nextSeq =
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
this.states.set(agentId, {
rows,
nextSeq,
});
}
delete(agentId: string): void {
this.states.delete(agentId);
}
getItems(agentId: string): AgentTimelineItem[] {
return this.requireState(agentId).rows.map((row) => row.item);
}
getRows(agentId: string): AgentTimelineRow[] {
return this.requireState(agentId).rows.map(cloneRow);
}
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
const state = this.requireState(agentId);
const direction = options?.direction ?? "tail";
const requestedLimit = options?.limit;
const limit =
requestedLimit === undefined
? DEFAULT_TIMELINE_FETCH_LIMIT
: Math.max(0, Math.floor(requestedLimit));
const cursor = options?.cursor;
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
const selectAll = limit === 0;
const window = {
minSeq,
maxSeq,
nextSeq: state.nextSeq,
};
if (state.rows.length === 0) {
return {
direction,
window,
hasOlder: false,
hasNewer: false,
rows: [],
};
}
if (direction === "tail") {
const selected =
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: false,
rows: selected.map(cloneRow),
};
}
if (direction === "after") {
const baseSeq = cursor?.seq ?? 0;
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
if (startIdx < 0) {
return {
direction,
window,
hasOlder: baseSeq >= minSeq,
hasNewer: false,
rows: [],
};
}
const selected = selectAll
? state.rows.slice(startIdx)
: state.rows.slice(startIdx, startIdx + limit);
const lastSelected = selected[selected.length - 1];
return {
direction,
window,
hasOlder: selected[0]!.seq > minSeq,
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
rows: selected.map(cloneRow),
};
}
const beforeSeq = cursor?.seq ?? state.nextSeq;
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
const selected =
selectAll || limit >= boundedRows.length
? boundedRows
: boundedRows.slice(boundedRows.length - limit);
return {
direction,
window,
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
hasNewer: endExclusive >= 0,
rows: selected.map(cloneRow),
};
}
append(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): AgentTimelineRow {
const state = this.requireState(agentId);
const row: AgentTimelineRow = {
seq: state.nextSeq,
timestamp: options?.timestamp ?? new Date().toISOString(),
item,
};
state.nextSeq += 1;
state.rows.push(row);
return cloneRow(row);
}
getLastItem(agentId: string): AgentTimelineItem | null {
const state = this.requireState(agentId);
return state.rows[state.rows.length - 1]?.item ?? null;
}
getLastAssistantMessage(agentId: string): string | null {
const rows = this.requireState(agentId).rows;
const chunks: string[] = [];
for (let i = rows.length - 1; i >= 0; i -= 1) {
const item = rows[i]!.item;
if (item.type !== "assistant_message") {
if (chunks.length > 0) {
break;
}
continue;
}
chunks.push(item.text);
}
if (chunks.length === 0) {
return null;
}
return chunks.reverse().join("");
}
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
if (row.item.type !== "user_message") {
return [];
}
const messageId = normalizeTimelineMessageId(row.item.messageId);
if (!messageId) {
return [];
}
return [[messageId, row.item.text]];
});
return new Map(entries);
}
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
const messageId = normalizeTimelineMessageId(options.messageId);
if (!messageId) {
return false;
}
return this.requireState(agentId).rows.some((row) => {
if (row.item.type !== "user_message") {
return false;
}
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
return rowMessageId === messageId && row.item.text === options.text;
});
}
private requireState(agentId: string): AgentTimelineState {
const state = this.states.get(agentId);
if (!state) {
throw new Error(`Unknown agent '${agentId}'`);
}
return state;
}
private buildRowsFromItems(
items: readonly AgentTimelineItem[],
startSeq: number,
timestamp: string,
): AgentTimelineRow[] {
let nextSeq = startSeq;
return items.map((item) => {
const row: AgentTimelineRow = {
seq: nextSeq,
timestamp,
item,
};
nextSeq += 1;
return row;
});
}
}
@@ -6,11 +6,11 @@ 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 { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
type TestDeps = {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
spies: {
agentManager: Record<string, any>;
agentStorage: Record<string, any>;
@@ -41,7 +41,7 @@ function createTestDeps(): TestDeps {
return {
agentManager: agentManagerSpies as unknown as AgentManager,
agentStorage: agentStorageSpies as unknown as AgentStorage,
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
spies: {
agentManager: agentManagerSpies,
agentStorage: agentStorageSpies,
@@ -16,7 +16,7 @@ import {
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
import { AgentStorage } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager?: TerminalManager | null;
paseoHome?: string;
/**
@@ -240,7 +240,7 @@ function sanitizePermissionRequest(
}
async function resolveAgentTitle(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -254,7 +254,7 @@ async function resolveAgentTitle(
}
async function serializeSnapshotWithMetadata(
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
snapshot: ManagedAgent,
logger: Logger,
) {
@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
@@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => {
]);
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
const queryMock = createSupportedModelsQueryMock([
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[]);
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
let promptClosedBeforeModelsResolved = false;
queryMock.supportedModels = vi.fn(async () => {
promptNextPromise = promptIterator?.next() ?? null;
if (!promptNextPromise) {
throw new Error("Prompt iterator not captured");
}
promptNextPromise.then(() => {
promptClosedBeforeModelsResolved = true;
});
await Promise.resolve();
expect(promptClosedBeforeModelsResolved).toBe(false);
return [
{
value: "default",
displayName: "Default (recommended)",
description: "Sonnet 4.6 · Best for everyday tasks",
},
] satisfies ModelInfo[];
});
const queryFactory = vi.fn(({ prompt }) => {
promptIterator = prompt[Symbol.asyncIterator]();
return queryMock;
});
const client = new ClaudeAgentClient({
logger,
queryFactory: queryFactory as never,
});
const models = await client.listModels({ cwd: process.cwd() });
expect(models).toHaveLength(1);
expect(promptNextPromise).not.toBeNull();
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
expect(queryMock.return).toHaveBeenCalledTimes(1);
});
});
@@ -225,10 +225,6 @@ function applyRuntimeSettingsToClaudeOptions(
};
}
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
return (async function* empty() {})();
}
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
return value === "low" || value === "medium" || value === "high" || value === "max";
}
@@ -1032,8 +1028,9 @@ export class ClaudeAgentClient implements AgentClient {
}
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
const input = createAsyncMessageInput<SDKUserMessage>();
const claudeQuery = this.queryFactory({
prompt: createEmptyClaudePrompt(),
prompt: input.iterable,
options: applyRuntimeSettingsToClaudeOptions(
{
cwd: options?.cwd ?? process.cwd(),
@@ -1052,13 +1049,13 @@ export class ClaudeAgentClient implements AgentClient {
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
throw error;
} finally {
input.end();
try {
await claudeQuery.return?.();
} catch {
// ignore control-plane shutdown errors
}
}
}
async listPersistedAgents(
@@ -1,5 +1,6 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -8,6 +9,8 @@ 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/pglite-database.js";
import { agentSnapshots, projects, workspaces } from "./db/schema.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -182,4 +185,224 @@ 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();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
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,
},
],
workspaces: [
{
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 daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await daemon.start();
await daemon.stop();
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(projects)).toEqual([
{
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,
},
]);
expect(await database.db.select().from(workspaces)).toEqual([
{
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,
},
]);
} finally {
await database.close();
}
} finally {
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
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,
},
],
workspaces: [
{
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,
},
],
});
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();
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 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();
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<void>;
}> {
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");
}
+47 -22
View File
@@ -84,12 +84,17 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { initializeSpeechRuntime } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-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 { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/pglite-database.js";
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
@@ -179,7 +184,7 @@ export type PaseoDaemonConfig = {
export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
terminalManager: TerminalManager;
start(): Promise<void>;
stop(): Promise<void>;
@@ -197,6 +202,7 @@ export async function createPaseoDaemon(
const pidLockMode = config.pidLock?.mode ?? "self";
const pidLockOwnerPid = config.pidLock?.ownerPid;
const ownsPidLock = pidLockMode === "self";
let database: PaseoDatabaseHandle | null = null;
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
if (ownsPidLock) {
@@ -356,15 +362,27 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
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,
);
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
logger.info({ elapsed: elapsed() }, "Paseo database opened");
if (process.env.NODE_ENV === "development") {
app.post("/dev/db-query", async (req, res) => {
try {
const { sql } = req.body;
if (typeof sql !== "string") {
res.status(400).json({ error: "Missing sql string" });
return;
}
const result = await database!.client.query(sql);
res.json({ rows: result.rows });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
}
const agentStorage = new DbAgentSnapshotStore(database.db);
const durableTimelineStore = new DbAgentTimelineStore(database.db);
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
@@ -373,18 +391,25 @@ export async function createPaseoDaemon(
...config.agentClients,
},
registry: agentStorage,
durableTimelineStore,
logger,
});
const terminalManager = createTerminalManager();
const detachAgentStoragePersistence = attachAgentStoragePersistence(
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: config.paseoHome,
logger,
agentManager,
agentStorage,
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
});
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
await importLegacyAgentSnapshots({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
@@ -709,8 +734,6 @@ export async function createPaseoDaemon(
const stop = async () => {
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
});
@@ -725,6 +748,7 @@ export async function createPaseoDaemon(
if (voiceMcpBridgeManager) {
await voiceMcpBridgeManager.stop().catch(() => undefined);
}
await database?.close().catch(() => undefined);
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
@@ -750,6 +774,7 @@ export async function createPaseoDaemon(
getListenTarget: () => boundListenTarget,
};
} catch (err) {
await database?.close().catch(() => undefined);
if (ownsPidLock) {
await releasePidLock(config.paseoHome, {
ownerPid: pidLockOwnerPid,
@@ -224,6 +224,8 @@ describe("daemon client E2E", () => {
expect(archivedResult).not.toBeNull();
expect(archivedResult?.agent.archivedAt).toBeTruthy();
expect(archivedResult?.agent.status).not.toBe("running");
expect(archivedResult?.agent.requiresAttention).toBe(false);
expect(archivedResult?.agent.attentionReason).toBeNull();
expect(archivedResult?.project).not.toBeNull();
expect(archivedResult?.project?.checkout.cwd).toBe(cwd);
@@ -309,6 +311,42 @@ describe("daemon client E2E", () => {
}
}, 180000);
test("update_agent persists unloaded title and labels across auto-unarchive", async () => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createAgent({
config: {
...getFullAccessConfig("codex"),
cwd,
},
});
await ctx.client.archiveAgent(created.id);
await ctx.client.updateAgent(created.id, {
name: "Pinned Title",
labels: { lane: "phase-1a" },
});
const archived = await ctx.client.fetchAgent(created.id);
expect(archived).not.toBeNull();
expect(archived?.agent.archivedAt).toBeTruthy();
expect(archived?.agent.title).toBe("Pinned Title");
expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" });
await ctx.client.sendMessage(created.id, "Say hello and nothing else");
const finalState = await ctx.client.waitForFinish(created.id, 120000);
expect(finalState.status).toBe("idle");
const unarchived = await ctx.client.fetchAgent(created.id);
expect(unarchived).not.toBeNull();
expect(unarchived?.agent.archivedAt).toBeNull();
expect(unarchived?.agent.title).toBe("Pinned Title");
expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" });
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 180000);
test("returns home-scoped directory suggestions", async () => {
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
@@ -529,7 +567,6 @@ describe("daemon client E2E", () => {
const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "projected",
});
expect(timelineResult.agentId).toBe(agent.id);
@@ -756,7 +793,6 @@ describe("daemon client E2E", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "projected",
});
expect(timeline.entries.length).toBeGreaterThan(0);
@@ -71,7 +71,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const refreshedResult = await ctx.client.fetchAgent(agent.id);
@@ -89,7 +88,6 @@ describe("daemon E2E", () => {
await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 200,
projection: "projected",
});
const clearResult = await ctx.client.fetchAgent(agent.id);
@@ -60,7 +60,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const idleAssistantText = timelineAtIdle.entries
.filter(
@@ -91,7 +90,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const finalAssistantText = finalTimeline.entries
.filter(
@@ -390,7 +390,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
await client.waitForAgentUpsert(
@@ -405,7 +404,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtIdle.entries.length,
@@ -417,7 +415,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const nextTimeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length;
}
@@ -605,7 +602,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry);
// Required by reproduction request: log timeline at idle edge before autonomous wake.
@@ -735,7 +731,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -751,7 +746,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -916,7 +910,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
// eslint-disable-next-line no-console
@@ -932,7 +925,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
timelineAtWake.entries.length,
@@ -1066,7 +1058,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
assistantTexts = timeline.entries
.filter(
@@ -114,11 +114,14 @@ describe("daemon E2E - persistence", () => {
const timeline = await ctx.client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const timelineItems = timeline.entries.map((entry) => entry.item);
expect(timelineItems.length).toBeGreaterThan(0);
expect(timelineItems.some((item) => item.type === "assistant_message")).toBe(true);
const assistantMessages = timelineItems.filter(
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
item.type === "assistant_message",
);
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
} finally {
await ctx.cleanup();
cleaned = true;
@@ -48,7 +48,6 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => {
const timeline = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 0,
projection: "canonical",
});
const rewindUserMessages = timeline.entries.filter(
@@ -0,0 +1,206 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js";
import { createMessageCollector } from "../test-utils/message-collector.js";
import type { SessionOutboundMessage } from "../messages.js";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
async function waitFor(
predicate: () => boolean,
timeoutMs = 5_000,
intervalMs = 10,
): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
function isSeqLessAssistantTimeline(
message: SessionOutboundMessage,
agentId: string,
text?: string,
): boolean {
return (
message.type === "agent_stream" &&
message.payload.agentId === agentId &&
message.payload.event.type === "timeline" &&
message.payload.event.item.type === "assistant_message" &&
message.payload.seq === undefined &&
(text === undefined || message.payload.event.item.text === text)
);
}
describe("daemon E2E - timeline reconnect contract", () => {
let ctx: DaemonTestContext;
beforeEach(async () => {
ctx = await createDaemonTestContext();
});
afterEach(async () => {
await ctx.cleanup();
}, 60_000);
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect Contract Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: "finalized while disconnected",
});
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-a" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(1);
expect(catchUp.entries[0]?.seq).toBe(121);
expect(catchUp.entries[0]?.item).toEqual({
type: "assistant_message",
text: "finalized while disconnected",
});
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
const cwd = tmpCwd();
const primaryCollector = createMessageCollector(ctx.client);
try {
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Reconnect No Seed Test",
modeId: "full-access",
});
for (let seq = 1; seq <= 120; seq += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "assistant_message",
text: `committed row ${seq}`,
});
}
primaryCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "partial before disconnect",
});
await waitFor(() =>
primaryCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
),
);
await ctx.client.close();
const reconnectClient = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
});
await reconnectClient.connect();
const reconnectCollector = createMessageCollector(reconnectClient);
try {
await reconnectClient.fetchAgents({
subscribe: { subscriptionId: "timeline-reconnect-b" },
});
expect(
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id),
),
).toBe(false);
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
direction: "after",
cursor: { seq: 120 },
limit: 0,
});
expect(catchUp.entries).toHaveLength(0);
reconnectCollector.clear();
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
type: "assistant_message",
text: "fresh live after reconnect",
});
await waitFor(() =>
reconnectCollector.messages.some((message) =>
isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"),
),
);
} finally {
reconnectCollector.unsubscribe();
await reconnectClient.close();
}
} finally {
primaryCollector.unsubscribe();
rmSync(cwd, { recursive: true, force: true });
}
}, 30_000);
});
@@ -20,7 +20,7 @@ describe("daemon E2E - timeline window", () => {
await ctx.cleanup();
}, 60_000);
test("canonical tail limit keeps assistant chunks intact at the window boundary", async () => {
test("canonical tail limit returns one finalized committed assistant row at the window boundary", async () => {
const cwd = tmpCwd();
try {
const agent = await ctx.client.createAgent({
@@ -38,16 +38,14 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
.filter((entry) => entry.item.type === "assistant_message")
.map((entry) => entry.item.text);
expect(assistantTexts).toHaveLength(2);
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0);
expect(assistantTexts).toEqual([expected]);
expect(timeline.startSeq).toBe(timeline.endSeq);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -73,7 +71,6 @@ describe("daemon E2E - timeline window", () => {
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 1,
projection: "canonical",
});
const assistantTexts = timeline.entries
@@ -82,7 +79,7 @@ describe("daemon E2E - timeline window", () => {
expect(assistantTexts.join("")).toBe(expected);
expect(timeline.hasOlder).toBe(true);
expect(timeline.startCursor?.seq).toBeGreaterThan(1);
expect(timeline.startSeq).toBeGreaterThan(1);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
@@ -289,7 +289,6 @@ async function resolveLatestAssistantMessage(
const timeline = await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: 300,
projection: "canonical",
});
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
const entry = timeline.entries[idx];
@@ -0,0 +1,267 @@
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 "./pglite-database.js";
import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
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<string, AgentPermissionRequest>(),
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> = {}): 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 () => {
await seedWorkspace(database, { workspaceId: "/tmp/project", projectId: "project-1" });
const record = createStoredAgentRecord();
expect(await store.list()).toEqual([]);
expect(await store.get(record.id)).toBeNull();
await store.upsert(record);
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: "/tmp/project",
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 () => {
await store.upsert(
createStoredAgentRecord({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
}),
);
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",
}),
);
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 () => {
await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow(
"Agent missing-agent not found",
);
await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }));
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 () => {
await store.upsert(createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }));
await store.upsert(
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
);
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: { workspaceId: string; projectId: string },
): Promise<void> {
await database.db.insert(projects).values({
projectId: options.projectId,
rootPath: options.workspaceId,
kind: "git",
displayName: options.projectId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await database.db.insert(workspaces).values({
workspaceId: options.workspaceId,
projectId: options.projectId,
cwd: options.workspaceId,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
}
@@ -0,0 +1,174 @@
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 { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { agentSnapshots, workspaces } 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: string | null;
}): 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<AgentSnapshotInsert, "agentId">;
}
export class DbAgentSnapshotStore implements AgentSnapshotStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async list(): Promise<StoredAgentRecord[]> {
const rows = await this.db
.select()
.from(agentSnapshots)
.orderBy(asc(agentSnapshots.createdAt), asc(agentSnapshots.agentId));
return rows.map(toStoredAgentRecordFromRow);
}
async get(agentId: string): Promise<StoredAgentRecord | null> {
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<void> {
const values = toAgentSnapshotRowValues({
record,
workspaceId: await this.resolveWorkspaceId(record.cwd),
});
await this.db
.insert(agentSnapshots)
.values(values)
.onConflictDoUpdate({
target: agentSnapshots.agentId,
set: toAgentSnapshotUpdateSet(values),
});
}
async remove(agentId: string): Promise<void> {
await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId));
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const existing = await this.get(agent.id);
const hasTitleOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
const hasInternalOverride =
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
});
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
await this.upsert(record);
}
async setTitle(agentId: string, title: string): Promise<void> {
const record = await this.get(agentId);
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
await this.upsert({ ...record, title });
}
private async resolveWorkspaceId(cwd: string): Promise<string | null> {
const workspaceId = normalizeWorkspaceId(cwd);
const rows = await this.db
.select({ workspaceId: workspaces.workspaceId })
.from(workspaces)
.where(eq(workspaces.workspaceId, workspaceId))
.limit(1);
return rows[0]?.workspaceId ?? null;
}
}
@@ -0,0 +1,266 @@
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 "./pglite-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<AgentTimelineItem["type"], "assistant_message" | "user_message">,
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);
});
});
@@ -0,0 +1,303 @@
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 "./pglite-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<AgentTimelineRow> {
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<AgentTimelineFetchResult> {
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<number> {
return this.getMaxSeq(agentId);
}
async getCommittedRows(agentId: string): Promise<AgentTimelineRow[]> {
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<AgentTimelineItem | null> {
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<string | null> {
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<boolean> {
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`${agentTimelineRows.item} ->> 'messageId' = ${messageId}`,
sql`${agentTimelineRows.item} ->> 'text' = ${options.text}`,
),
)
.limit(1);
return row !== undefined;
}
async deleteAgent(agentId: string): Promise<void> {
await this.db.delete(agentTimelineRows).where(eq(agentTimelineRows.agentId, agentId));
}
async bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void> {
if (rows.length === 0) {
return;
}
await this.db.insert(agentTimelineRows).values(rows.map((row) => toInsertValues(agentId, row)));
}
private async getMaxSeq(agentId: string): Promise<number> {
const [row] = await this.db
.select({
maxSeq: sql<number>`coalesce(max(${agentTimelineRows.seq}), 0)`,
})
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, agentId));
return Number(row?.maxSeq ?? 0);
}
private async getWindow(agentId: string): Promise<AgentTimelineWindow> {
const [row] = await this.db
.select({
minSeq: sql<number>`coalesce(min(${agentTimelineRows.seq}), 0)`,
maxSeq: sql<number>`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,
};
}
}
@@ -0,0 +1,73 @@
import { eq } from "drizzle-orm";
import type { PersistedProjectKind } from "../workspace-registry-model.js";
import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord } from "../workspace-registry.js";
import { projects } from "./schema.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord {
return createPersistedProjectRecord({
...row,
kind: row.kind as PersistedProjectKind,
});
}
export class DbProjectRegistry implements ProjectRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedProjectRecord[]> {
const rows = await this.db.select().from(projects);
return rows.map(toPersistedProjectRecord);
}
async get(projectId: string): Promise<PersistedProjectRecord | null> {
const rows = await this.db.select().from(projects).where(eq(projects.projectId, projectId)).limit(1);
const row = rows[0];
return row ? toPersistedProjectRecord(row) : null;
}
async upsert(record: PersistedProjectRecord): Promise<void> {
const nextRecord = createPersistedProjectRecord(record);
await this.db
.insert(projects)
.values(nextRecord)
.onConflictDoUpdate({
target: projects.projectId,
set: {
rootPath: nextRecord.rootPath,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(projectId: string, archivedAt: string): Promise<void> {
await this.db
.update(projects)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(projects.projectId, projectId));
}
async remove(projectId: string): Promise<void> {
await this.db.delete(projects).where(eq(projects.projectId, projectId));
}
}
@@ -0,0 +1,154 @@
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, ProjectRegistry, WorkspaceRegistry } from "../workspace-registry.js";
import { createPersistedProjectRecord, createPersistedWorkspaceRecord } from "../workspace-registry.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-database.js";
import { DbProjectRegistry } from "./db-project-registry.js";
import { DbWorkspaceRegistry } from "./db-workspace-registry.js";
function createProjectRecord(input: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
function createWorkspaceRecord(input: Partial<PersistedWorkspaceRecord> = {}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo",
kind: "local_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: ProjectRegistry;
let workspaceRegistry: WorkspaceRegistry;
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("missing-project")).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
await projectRegistry.upsert(createProjectRecord());
await projectRegistry.upsert(
createProjectRecord({
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await projectRegistry.archive(
"remote:github.com/acme/repo",
"2026-03-03T00:00:00.000Z",
);
await projectRegistry.archive("missing-project", "2026-03-04T00:00:00.000Z");
expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual(
createProjectRecord({
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("missing-project");
await projectRegistry.remove("remote:github.com/acme/repo");
expect(await projectRegistry.get("remote:github.com/acme/repo")).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("missing-workspace")).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
await projectRegistry.upsert(createProjectRecord());
await workspaceRegistry.upsert(createWorkspaceRecord());
await workspaceRegistry.upsert(
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z");
await workspaceRegistry.archive("missing-workspace", "2026-03-04T00:00:00.000Z");
expect(await workspaceRegistry.get("/tmp/repo")).toEqual(
createWorkspaceRecord({
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("missing-workspace");
await workspaceRegistry.remove("/tmp/repo");
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
});
test("rejects workspace upserts for non-existent projects", async () => {
await expect(
workspaceRegistry.upsert(
createWorkspaceRecord({
projectId: "remote:github.com/acme/missing",
}),
),
).rejects.toThrow();
});
test("rejects project removal while linked workspaces exist", async () => {
await projectRegistry.upsert(createProjectRecord());
await workspaceRegistry.upsert(createWorkspaceRecord());
await expect(projectRegistry.remove("remote:github.com/acme/repo")).rejects.toThrow();
expect(await projectRegistry.get("remote:github.com/acme/repo")).toEqual(createProjectRecord());
});
});
@@ -0,0 +1,78 @@
import { eq } from "drizzle-orm";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { workspaces } from "./schema.js";
import type { PersistedWorkspaceKind } from "../workspace-registry-model.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 PersistedWorkspaceKind,
});
}
export class DbWorkspaceRegistry implements WorkspaceRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<void> {
return Promise.resolve();
}
async existsOnDisk(): Promise<boolean> {
return true;
}
async list(): Promise<PersistedWorkspaceRecord[]> {
const rows = await this.db.select().from(workspaces);
return rows.map(toPersistedWorkspaceRecord);
}
async get(workspaceId: string): Promise<PersistedWorkspaceRecord | null> {
const rows = await this.db
.select()
.from(workspaces)
.where(eq(workspaces.workspaceId, workspaceId))
.limit(1);
const row = rows[0];
return row ? toPersistedWorkspaceRecord(row) : null;
}
async upsert(record: PersistedWorkspaceRecord): Promise<void> {
const nextRecord = createPersistedWorkspaceRecord(record);
await this.db
.insert(workspaces)
.values(nextRecord)
.onConflictDoUpdate({
target: workspaces.workspaceId,
set: {
projectId: nextRecord.projectId,
cwd: nextRecord.cwd,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
createdAt: nextRecord.createdAt,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(workspaceId: string, archivedAt: string): Promise<void> {
await this.db
.update(workspaces)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(workspaces.workspaceId, workspaceId));
}
async remove(workspaceId: string): Promise<void> {
await this.db.delete(workspaces).where(eq(workspaces.workspaceId, workspaceId));
}
}
@@ -0,0 +1,173 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, 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 "./pglite-database.js";
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
import { agentSnapshots } 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 });
});
test("imports agent JSON files when the DB is empty", async () => {
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 () => {
await database.db.insert(agentSnapshots).values({
agentId: "existing-agent",
provider: "codex",
workspaceId: null,
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 () => {
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"]);
});
});
function createLegacyAgentJson(overrides: Record<string, unknown> = {}): Record<string, unknown> {
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<string, unknown>;
}): 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",
});
}
@@ -0,0 +1,128 @@
import path from "node:path";
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 { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
import { agentSnapshots, workspaces } from "./schema.js";
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<LegacyAgentSnapshotImportResult> {
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 records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger);
if (records.length === 0) {
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
await options.db.transaction(async (tx) => {
const workspaceRows = await tx.select({ workspaceId: workspaces.workspaceId }).from(workspaces);
const workspaceIds = new Set(workspaceRows.map((row) => row.workspaceId));
const rows = records.map((record) => {
const workspaceId = normalizeWorkspaceId(record.cwd);
return toAgentSnapshotRowValues({
record,
workspaceId: workspaceIds.has(workspaceId) ? workspaceId : null,
});
});
await tx.insert(agentSnapshots).values(rows);
});
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<StoredAgentRecord[]> {
let entries: Array<import("node:fs").Dirent> = [];
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return [];
}
throw error;
}
const recordsById = new Map<string, StoredAgentRecord>();
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);
}
continue;
}
if (!entry.isDirectory()) {
continue;
}
let childEntries: Array<import("node:fs").Dirent> = [];
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);
}
}
}
return Array.from(recordsById.values());
}
async function readRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null> {
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<boolean> {
const rows = await db.select({ count: count() }).from(agentSnapshots);
return (rows[0]?.count ?? 0) > 0;
}
@@ -0,0 +1,191 @@
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { eq } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./pglite-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,
});
expect(await database.db.select().from(projects)).toEqual([
{
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,
},
]);
expect(await database.db.select().from(workspaces)).toEqual([
{
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,
},
]);
});
test("skips import when the DB already has project or workspace data", async () => {
await database.db.insert(projects).values({
projectId: "existing-project",
rootPath: "/tmp/existing-project",
kind: "git",
displayName: "Existing Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
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",
});
expect(
await database.db.select().from(projects).where(eq(projects.projectId, "legacy-project")),
).toEqual([]);
});
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([]);
});
});
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",
});
}
@@ -0,0 +1,109 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import {
parsePersistedProjectRecords,
parsePersistedWorkspaceRecords,
} from "../workspace-registry.js";
import type { PaseoDatabaseHandle } from "./pglite-database.js";
import { projects, workspaces } from "./schema.js";
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<LegacyProjectWorkspaceImportResult> {
const projectsPath = path.join(options.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json");
const [projectRows, workspaceRows, databaseHasRows] = await Promise.all([
readLegacyProjects(projectsPath),
readLegacyWorkspaces(workspacesPath),
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",
};
}
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",
};
}
await options.db.transaction(async (tx) => {
if (projectRows.length > 0) {
await tx.insert(projects).values(projectRows);
}
if (workspaceRows.length > 0) {
await tx.insert(workspaces).values(workspaceRows);
}
});
options.logger.info(
{
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
},
"Imported legacy project/workspace JSON into the database",
);
return {
status: "imported",
importedProjects: projectRows.length,
importedWorkspaces: workspaceRows.length,
};
}
async function readLegacyProjects(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? parsePersistedProjectRecords(raw) : [];
}
async function readLegacyWorkspaces(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
return raw ? parsePersistedWorkspaceRecords(raw) : [];
}
async function readOptionalJsonFile(filePath: string): Promise<unknown | null> {
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<boolean> {
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;
}
@@ -0,0 +1,11 @@
import { fileURLToPath } from "node:url";
import type { PgliteDatabase } from "drizzle-orm/pglite";
import { migrate } from "drizzle-orm/pglite/migrator";
const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url));
export async function runPaseoDbMigrations(db: PgliteDatabase<typeof import("./schema.js").paseoDbSchema>): Promise<void> {
await migrate(db, { migrationsFolder });
}
@@ -0,0 +1,56 @@
CREATE TABLE "agent_snapshots" (
"agent_id" text PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"workspace_id" text,
"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" jsonb NOT NULL,
"last_status" text NOT NULL,
"last_mode_id" text,
"config" jsonb,
"runtime_info" jsonb,
"persistence" jsonb,
"requires_attention" boolean NOT NULL,
"attention_reason" text,
"attention_timestamp" text,
"internal" boolean NOT NULL,
"archived_at" text
);
--> statement-breakpoint
CREATE TABLE "agent_timeline_rows" (
"agent_id" text NOT NULL,
"seq" integer NOT NULL,
"committed_at" text NOT NULL,
"item" jsonb NOT NULL,
"item_kind" text,
CONSTRAINT "agent_timeline_rows_pk" PRIMARY KEY("agent_id","seq")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"project_id" text PRIMARY KEY NOT NULL,
"root_path" text NOT NULL,
"kind" text NOT NULL,
"display_name" text NOT NULL,
"created_at" text NOT NULL,
"updated_at" text NOT NULL,
"archived_at" text
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"workspace_id" text PRIMARY KEY NOT NULL,
"project_id" text NOT NULL,
"cwd" text NOT NULL,
"kind" text NOT NULL,
"display_name" text NOT NULL,
"created_at" text NOT NULL,
"updated_at" text NOT NULL,
"archived_at" text
);
--> statement-breakpoint
ALTER TABLE "agent_snapshots" ADD CONSTRAINT "agent_snapshots_workspace_id_workspaces_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("workspace_id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_project_id_projects_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."projects"("project_id") ON DELETE restrict ON UPDATE cascade;--> statement-breakpoint
CREATE INDEX "workspaces_project_id_idx" ON "workspaces" USING btree ("project_id");
@@ -0,0 +1,363 @@
{
"id": "5940c904-fd8b-4276-953d-acc382bf3fb8",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent_snapshots": {
"name": "agent_snapshots",
"schema": "",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_activity_at": {
"name": "last_activity_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_user_message_at": {
"name": "last_user_message_at",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false
},
"labels": {
"name": "labels",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"last_status": {
"name": "last_status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_mode_id": {
"name": "last_mode_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"runtime_info": {
"name": "runtime_info",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"persistence": {
"name": "persistence",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"requires_attention": {
"name": "requires_attention",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"attention_reason": {
"name": "attention_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"attention_timestamp": {
"name": "attention_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false
},
"internal": {
"name": "internal",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_snapshots_workspace_id_workspaces_workspace_id_fk": {
"name": "agent_snapshots_workspace_id_workspaces_workspace_id_fk",
"tableFrom": "agent_snapshots",
"tableTo": "workspaces",
"columnsFrom": [
"workspace_id"
],
"columnsTo": [
"workspace_id"
],
"onDelete": "set null",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_timeline_rows": {
"name": "agent_timeline_rows",
"schema": "",
"columns": {
"agent_id": {
"name": "agent_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seq": {
"name": "seq",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"committed_at": {
"name": "committed_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"item": {
"name": "item",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"item_kind": {
"name": "item_kind",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"agent_timeline_rows_pk": {
"name": "agent_timeline_rows_pk",
"columns": [
"agent_id",
"seq"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"root_path": {
"name": "root_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workspaces": {
"name": "workspaces",
"schema": "",
"columns": {
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cwd": {
"name": "cwd",
"type": "text",
"primaryKey": false,
"notNull": true
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true
},
"archived_at": {
"name": "archived_at",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"workspaces_project_id_idx": {
"name": "workspaces_project_id_idx",
"columns": [
{
"expression": "project_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"workspaces_project_id_projects_project_id_fk": {
"name": "workspaces_project_id_projects_project_id_fk",
"tableFrom": "workspaces",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"project_id"
],
"onDelete": "restrict",
"onUpdate": "cascade"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774405361702,
"tag": "0000_youthful_lady_mastermind",
"breakpoints": true
}
]
}
@@ -0,0 +1,264 @@
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 "./pglite-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("PGlite 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);
await database.db.insert(projects).values({
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.close();
const reopened = await openPaseoDatabase(dataDir);
const rows = await reopened.db.select().from(projects);
expect(rows).toEqual([
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
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);
await database.db.insert(projects).values({
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.db.insert(workspaces).values({
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
});
await database.db
.update(workspaces)
.set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) })
.where(eq(workspaces.workspaceId, "workspace-1"));
const linkedRows = await database.db
.select({
projectId: projects.projectId,
workspaceId: workspaces.workspaceId,
workspaceArchivedAt: workspaces.archivedAt,
})
.from(workspaces)
.innerJoin(projects, eq(workspaces.projectId, projects.projectId));
expect(linkedRows).toEqual([
{
projectId: "project-1",
workspaceId: "workspace-1",
workspaceArchivedAt: createTimestamp(2),
},
]);
await database.close();
});
test("supports snapshot insert, get, and update with the current persisted metadata shape", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
workspaceId: null,
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: null,
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.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 = await database.db.execute(
sql`select * from drizzle.__drizzle_migrations order by created_at`,
);
expect(migrationRows.rows).toHaveLength(1);
await database.close();
});
});
@@ -0,0 +1,24 @@
import { PGlite } from "@electric-sql/pglite";
import { drizzle } from "drizzle-orm/pglite";
import { runPaseoDbMigrations } from "./migrations.js";
import { paseoDbSchema } from "./schema.js";
export interface PaseoDatabaseHandle {
client: PGlite;
db: ReturnType<typeof drizzle<typeof paseoDbSchema>>;
close(): Promise<void>;
}
export async function openPaseoDatabase(dataDir: string): Promise<PaseoDatabaseHandle> {
const client = new PGlite(dataDir);
const db = drizzle(client, { schema: paseoDbSchema });
await runPaseoDbMigrations(db);
return {
client,
db,
async close(): Promise<void> {
await client.close();
},
};
}
+78
View File
@@ -0,0 +1,78 @@
import { boolean, index, integer, jsonb, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
import type { AgentPersistenceHandle, AgentRuntimeInfo, AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
export const projects = pgTable("projects", {
projectId: text("project_id").primaryKey(),
rootPath: text("root_path").notNull(),
kind: text("kind").notNull(),
displayName: text("display_name").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
});
export const workspaces = pgTable(
"workspaces",
{
workspaceId: text("workspace_id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.projectId, { onDelete: "restrict", onUpdate: "cascade" }),
cwd: text("cwd").notNull(),
kind: text("kind").notNull(),
displayName: text("display_name").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 = pgTable("agent_snapshots", {
agentId: text("agent_id").primaryKey(),
provider: text("provider").notNull(),
workspaceId: text("workspace_id").references(() => workspaces.workspaceId, {
onDelete: "set null",
onUpdate: "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: jsonb("labels").$type<StoredAgentRecord["labels"]>().notNull(),
lastStatus: text("last_status").notNull(),
lastModeId: text("last_mode_id"),
config: jsonb("config").$type<StoredAgentRecord["config"]>(),
runtimeInfo: jsonb("runtime_info").$type<AgentRuntimeInfo>(),
persistence: jsonb("persistence").$type<AgentPersistenceHandle>(),
requiresAttention: boolean("requires_attention").notNull(),
attentionReason: text("attention_reason"),
attentionTimestamp: text("attention_timestamp"),
internal: boolean("internal").notNull(),
archivedAt: text("archived_at"),
});
export const agentTimelineRows = pgTable(
"agent_timeline_rows",
{
agentId: text("agent_id").notNull(),
seq: integer("seq").notNull(),
committedAt: text("committed_at").notNull(),
item: jsonb("item").$type<AgentTimelineItem>().notNull(),
itemKind: text("item_kind"),
},
(table) => [
primaryKey({ columns: [table.agentId, table.seq], name: "agent_timeline_rows_pk" }),
],
);
export const paseoDbSchema = {
projects,
workspaces,
agentSnapshots,
agentTimelineRows,
};
@@ -1,83 +1,6 @@
import { describe, expect, test, vi } from "vitest";
import type { ManagedAgent } from "./agent/agent-manager.js";
import { describe, expect, test } from "vitest";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import {
attachAgentStoragePersistence,
buildConfigOverrides,
buildSessionConfig,
} from "./persistence-hooks.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "./agent/agent-sdk-types.js";
const testLogger = {
child: () => testLogger,
error: vi.fn(),
} as any;
type ManagedAgentOverrides = Omit<
Partial<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
session?: AgentSession | null;
activeForegroundTurnId?: string | null;
};
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
const provider = overrides.provider ?? "claude";
const cwd = overrides.cwd ?? "/tmp/project";
const lifecycle = overrides.lifecycle ?? "idle";
const configOverrides = overrides.config ?? {};
const config: AgentSessionConfig = {
provider,
cwd,
modeId: configOverrides.modeId ?? "plan",
model: configOverrides.model ?? "claude-3.5-sonnet",
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
};
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
const activeForegroundTurnId =
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
const agent: ManagedAgent = {
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<string, AgentPermissionRequest>(),
activeForegroundTurnId,
foregroundTurnWaiters: new Set(),
unsubscribeSession: null,
timeline: overrides.timeline ?? [],
persistence: overrides.persistence ?? null,
historyPrimed: overrides.historyPrimed ?? true,
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
lastUsage: overrides.lastUsage,
lastError: overrides.lastError,
};
return agent;
}
import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js";
function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = new Date().toISOString();
@@ -100,43 +23,6 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
}
describe("persistence hooks", () => {
test("attachAgentStoragePersistence forwards agent snapshots", async () => {
const applySnapshot = vi.fn().mockResolvedValue(undefined);
let subscriber: (event: any) => void = () => {
throw new Error("Agent manager subscriber was not registered");
};
const agentManager = {
subscribe: vi.fn((callback: (event: any) => void) => {
subscriber = callback;
return () => {
subscriber = () => {
throw new Error("Agent manager subscriber was not registered");
};
};
}),
};
attachAgentStoragePersistence(
testLogger,
agentManager as any,
{
applySnapshot,
list: vi.fn(),
} as any,
);
expect(agentManager.subscribe).toHaveBeenCalledTimes(1);
const agent = createManagedAgent();
subscriber({ type: "agent_state", agent });
expect(applySnapshot).toHaveBeenCalledWith(agent);
subscriber({
type: "agent_stream",
agentId: agent.id,
event: { type: "timeline", item: { type: "assistant_message", text: "hi" } },
});
expect(applySnapshot).toHaveBeenCalledTimes(1);
});
test("buildConfigOverrides carries systemPrompt and mcpServers", () => {
const record = createRecord({
title: "Voice agent (current)",
+28 -36
View File
@@ -1,45 +1,13 @@
import type { AgentManager } from "./agent/agent-manager.js";
import type pino from "pino";
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
type LoggerLike = {
child(bindings: Record<string, unknown>): LoggerLike;
error(...args: any[]): void;
};
function getLogger(logger: LoggerLike): LoggerLike {
return logger.child({ module: "persistence" });
}
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import { isValidAgentProvider } from "./agent/provider-manifest.js";
function isKnownProvider(provider: string): provider is AgentProvider {
return provider === "claude" || provider === "codex" || provider === "opencode";
}
/**
* Attach AgentStorage persistence to an AgentManager instance so every
* agent_state snapshot is flushed to disk.
*/
export function attachAgentStoragePersistence(
logger: LoggerLike,
agentManager: AgentManagerStateSource,
storage: AgentStoragePersistence,
): () => void {
const log = getLogger(logger);
const unsubscribe = agentManager.subscribe((event) => {
if (event.type !== "agent_state") {
return;
}
void storage.applySnapshot(event.agent).catch((error) => {
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
});
});
return unsubscribe;
}
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
return {
cwd: record.cwd,
@@ -71,6 +39,30 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
};
}
export function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
) {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
};
}
export function extractTimestamps(record: StoredAgentRecord): {
createdAt: Date;
updatedAt: Date;
@@ -0,0 +1,15 @@
import { readFileSync } from "node:fs";
import { describe, expect, test } from "vitest";
describe("agent loading boundary", () => {
test("session runtime code does not directly hydrate provider history", () => {
const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8");
const agentLoadingSource = readFileSync(
new URL("./agent-loading-service.ts", import.meta.url),
"utf8",
);
expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
});
});
@@ -0,0 +1,384 @@
import { mkdtempSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import pino from "pino";
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/pglite-database.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
const now = "2026-03-25T00:00:00.000Z";
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: now,
updatedAt: now,
title: null,
labels: {},
lastStatus: "idle",
config: {
model: "gpt-5.1-codex-mini",
},
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-compat-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
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 });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([{ type: "assistant_message", text: "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" });
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000302",
});
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.flush();
await storage.flush();
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
} finally {
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => {
const deferred = createDeferred<any>();
let currentAgent: any = null;
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" });
const agentStorage = {
get: vi.fn(async () =>
createStoredAgentRecord({
id: "agent-compat-dedupe",
cwd: "/tmp/dedupe",
persistence: {
provider: "codex",
sessionId: "provider-session-dedupe",
},
}),
),
};
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(async () => deferred.promise),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: agentStorage as any,
logger: logger as any,
});
const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
deferred.resolve(snapshot);
const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]);
expect(firstResult).toEqual(snapshot);
expect(secondResult).toEqual(snapshot);
expect(agentStorage.get).toHaveBeenCalledTimes(1);
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1);
});
test("resumeAgent delegates to manager resume", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" });
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.resumeAgent({
handle: {
provider: "codex",
sessionId: "provider-session-resume",
},
overrides: {
model: "gpt-5.4",
},
});
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-resume",
},
{
model: "gpt-5.4",
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent reloads loaded persisted agents", async () => {
const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
let currentAgent: any = existing;
const agentManager = {
getAgent: vi.fn(() => currentAgent),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(async () => {
currentAgent = reloaded;
return reloaded;
}),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" });
expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded");
expect(result).toEqual(reloaded);
});
test("refreshAgent keeps loaded non-persisted agents without reloading", async () => {
const existing = createCompatibilitySnapshot({
id: "agent-compat-refresh-live",
persistence: null,
});
const agentManager = {
getAgent: vi.fn(() => existing),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: async () => null,
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" });
expect(agentManager.reloadAgentSession).not.toHaveBeenCalled();
expect(result).toEqual(existing);
});
test("refreshAgent resumes unloaded persisted agents", async () => {
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" });
const record = createStoredAgentRecord({
id: "agent-compat-refresh-cold",
cwd: "/tmp/refresh-cold",
persistence: {
provider: "codex",
sessionId: "provider-session-refresh-cold",
},
});
const agentManager = {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(async () => snapshot),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
};
const logger = {
child: () => logger,
info: vi.fn(),
warn: vi.fn(),
};
const service = new AgentLoadingService({
agentManager: agentManager as any,
agentStorage: {
get: vi.fn(async () => record),
} as any,
logger: logger as any,
});
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" });
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
{
provider: "codex",
sessionId: "provider-session-refresh-cold",
nativeHandle: undefined,
metadata: undefined,
},
{
cwd: "/tmp/refresh-cold",
modeId: undefined,
model: "gpt-5.1-codex-mini",
thinkingOptionId: undefined,
title: undefined,
extra: undefined,
systemPrompt: undefined,
mcpServers: undefined,
},
"agent-compat-refresh-cold",
{
createdAt: new Date("2026-03-25T00:00:00.000Z"),
updatedAt: new Date("2026-03-25T00:00:00.000Z"),
lastUserMessageAt: null,
labels: {},
},
);
expect(result).toEqual(snapshot);
});
test("refreshAgent preserves the unloaded no-persistence error", async () => {
const service = new AgentLoadingService({
agentManager: {
getAgent: vi.fn(() => null),
resumeAgentFromPersistence: vi.fn(),
createAgent: vi.fn(),
reloadAgentSession: vi.fn(),
} as any,
agentStorage: {
get: async () =>
createStoredAgentRecord({
id: "agent-compat-no-persistence",
persistence: null,
}),
} as any,
logger: {
child: () => ({
child: () => null,
info: vi.fn(),
warn: vi.fn(),
}),
info: vi.fn(),
warn: vi.fn(),
} as any,
});
await expect(
service.refreshAgent({ agentId: "agent-compat-no-persistence" }),
).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence");
});
});
@@ -0,0 +1,330 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
function createStoredAgentRecord(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: "2026-03-24T00:00:00.000Z",
title: null,
labels: {},
lastStatus: "idle",
config: null,
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
archivedAt: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
...overrides,
};
}
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
return {
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
...overrides,
};
}
function createSessionForOwnershipTests(options?: {
agentLoadingService?: {
ensureAgentLoaded?: (options: { agentId: string }) => Promise<any>;
resumeAgent?: (options: {
handle: { provider: string; sessionId: string };
overrides?: Record<string, unknown>;
}) => Promise<any>;
refreshAgent?: (options: { agentId: string }) => Promise<any>;
};
storedRecord?: Record<string, unknown> | null;
loadedAgent?: Record<string, unknown> | null;
timelineRows?: Array<{ seq: number; item: Record<string, unknown>; timestamp: Date }>;
}) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentManager = {
subscribe: () => () => {},
listAgents: () => [],
getAgent: vi.fn(() => options?.loadedAgent ?? null),
createAgent: vi.fn(async () => {
throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService");
}),
resumeAgentFromPersistence: vi.fn(async () => {
throw new Error("Session should delegate persistence resume to AgentLoadingService");
}),
reloadAgentSession: vi.fn(async () => {
throw new Error("Session should delegate refresh reload to AgentLoadingService");
}),
hydrateTimelineFromProvider: vi.fn(async () => {
throw new Error("Session should not call hydrateTimelineFromProvider directly");
}),
fetchTimeline: vi.fn(async () => ({
rows: options?.timelineRows ?? [],
hasOlder: false,
hasNewer: false,
})),
recordUserMessage: vi.fn(),
waitForAgentRunStart: vi.fn(async () => undefined),
getTimeline: vi.fn(() => []),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: agentManager as any,
agentStorage: {
list: async () => (options?.storedRecord ? [options.storedRecord as any] : []),
get: async () => (options?.storedRecord as any) ?? null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
agentLoadingService: options?.agentLoadingService,
} as any) as any;
return { session, emitted, agentManager };
}
describe("provider history compatibility ownership", () => {
test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
timelineRows: [
{
seq: 1,
item: { type: "assistant_message", text: "rehydrated from provider history" },
timestamp: new Date("2026-03-24T00:00:01.000Z"),
},
],
agentLoadingService: {
ensureAgentLoaded,
},
});
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "fetch_agent_timeline_request",
requestId: "req-fetch",
agentId: "agent-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "fetch_agent_timeline_response",
payload: expect.objectContaining({
requestId: "req-fetch",
agentId: "agent-1",
error: null,
entries: [
expect.objectContaining({
seq: 1,
}),
],
}),
});
});
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
const { session, agentManager, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
ensureAgentLoaded,
},
});
session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" }));
session.unarchiveAgentState = vi.fn(async () => true);
session.buildAgentPrompt = vi.fn((text: string) => text);
session.startAgentStream = vi.fn(() => ({ ok: true }));
await session.handleMessage({
type: "send_agent_message_request",
requestId: "req-send",
agentId: "agent-1",
text: "hello",
images: [],
messageId: "msg-1",
});
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan(
agentManager.recordUserMessage.mock.invocationCallOrder[0],
);
expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", {
messageId: "msg-1",
emitState: false,
});
expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello");
expect(emitted).toContainEqual({
type: "send_agent_message_response",
payload: {
requestId: "req-send",
agentId: "agent-1",
accepted: true,
error: null,
},
});
});
test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => {
const resumeAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
agentLoadingService: {
resumeAgent,
},
});
session.unarchiveAgentByHandle = vi.fn(async () => undefined);
session.unarchiveAgentState = vi.fn(async () => true);
session.forwardAgentUpdate = vi.fn(async () => undefined);
session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" }));
await session.handleMessage({
type: "resume_agent_request",
requestId: "req-resume",
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(resumeAgent).toHaveBeenCalledWith({
handle: {
provider: "codex",
sessionId: "provider-session-1",
},
overrides: {
model: "gpt-5.4",
},
});
expect(emitted).toContainEqual({
type: "status",
payload: expect.objectContaining({
status: "agent_resumed",
requestId: "req-resume",
agentId: "agent-1",
}),
});
});
test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () =>
createCompatibilitySnapshot({
persistence: {
provider: "codex",
sessionId: "provider-session-1",
},
}),
);
const { session, emitted } = createSessionForOwnershipTests({
loadedAgent: createCompatibilitySnapshot(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-loaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-loaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => {
const refreshAgent = vi.fn(async () => createCompatibilitySnapshot());
const { session, emitted } = createSessionForOwnershipTests({
storedRecord: createStoredAgentRecord(),
agentLoadingService: {
refreshAgent,
},
});
session.unarchiveAgentState = vi.fn(async () => true);
session.interruptAgentIfRunning = vi.fn(async () => undefined);
session.forwardAgentUpdate = vi.fn(async () => undefined);
await session.handleMessage({
type: "refresh_agent_request",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
});
expect(session.interruptAgentIfRunning).not.toHaveBeenCalled();
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
expect(emitted).toContainEqual({
type: "status",
payload: {
status: "agent_refreshed",
requestId: "req-refresh-unloaded",
agentId: "agent-1",
timelineSize: 0,
},
});
});
});
+84 -507
View File
@@ -54,9 +54,7 @@ import {
type VoiceTurnController,
} from "./voice/voice-turn-controller.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractTimestamps,
toAgentPersistenceHandle,
} from "./persistence-hooks.js";
import { experimental_createMCPClient } from "ai";
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
@@ -78,11 +76,6 @@ import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
} from "./agent/timeline-append.js";
import {
projectTimelineRows,
selectTimelineWindowByProjectedLimit,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import {
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
StructuredAgentFallbackError,
@@ -100,15 +93,12 @@ import type {
AgentProvider,
AgentPersistenceHandle,
} from "./agent/agent-sdk-types.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import {
buildProjectPlacementForCwd,
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
@@ -117,10 +107,8 @@ import type {
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { AgentLoadingService } from "./agent-loading-service.js";
import {
buildVoiceAgentMcpServerConfig,
buildVoiceModeSystemPrompt,
@@ -184,7 +172,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
GIT_OPTIONAL_LOCKS: "0",
};
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150;
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000;
@@ -194,6 +181,16 @@ const TERMINAL_STREAM_HIGH_WATER_BYTES = 256 * 1024;
const TERMINAL_STREAM_LOW_WATER_BYTES = 16 * 1024;
const MAX_TERMINAL_STREAM_SLOTS = 256;
type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
beginDelete(agentId: string): void;
};
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
}
}
function deriveInitialAgentTitle(prompt: string): string | null {
const firstContentLine = prompt
.split(/\r?\n/)
@@ -402,9 +399,11 @@ export type SessionOptions = {
pushTokenStore: PushTokenStore;
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentStorage;
agentStorage: AgentSnapshotStore;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
workspaceReconciliationService?: WorkspaceReconciliationService;
agentLoadingService?: AgentLoadingService;
createAgentMcpTransport: AgentMcpTransportFactory;
stt: Resolvable<SpeechToTextProvider | null>;
tts: Resolvable<TextToSpeechProvider | null>;
@@ -513,30 +512,6 @@ function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: strin
return DEFAULT_AGENT_PROVIDER;
}
function toAgentPersistenceHandle(
logger: pino.Logger,
handle: StoredAgentRecord["persistence"],
): AgentPersistenceHandle | null {
if (!handle) {
return null;
}
const provider = handle.provider;
if (!isValidAgentProvider(provider)) {
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
return null;
}
if (!handle.sessionId) {
logger.warn("Ignoring persistence handle missing sessionId");
return null;
}
return {
provider,
sessionId: handle.sessionId,
nativeHandle: handle.nativeHandle,
metadata: handle.metadata,
} satisfies AgentPersistenceHandle;
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -585,9 +560,11 @@ export class Session {
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly workspaceReconciliationService: WorkspaceReconciliationService;
private readonly agentLoadingService: AgentLoadingService;
private readonly createAgentMcpTransport: AgentMcpTransportFactory;
private readonly downloadTokenStore: DownloadTokenStore;
private readonly pushTokenStore: PushTokenStore;
@@ -648,6 +625,8 @@ export class Session {
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceReconciliationService,
agentLoadingService,
createAgentMcpTransport,
stt,
tts,
@@ -666,10 +645,33 @@ export class Session {
this.downloadTokenStore = downloadTokenStore;
this.pushTokenStore = pushTokenStore;
this.paseoHome = paseoHome;
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry;
this.workspaceReconciliationService =
workspaceReconciliationService ??
new WorkspaceReconciliationService({
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
agentStorage: this.agentStorage,
buildProjectPlacement: async (cwd) => this.buildProjectPlacement(cwd),
syncWorkspaceGitWatchTarget: async (cwd, options) =>
this.syncWorkspaceGitWatchTarget(cwd, options),
removeWorkspaceGitWatchTarget: async (cwd) => this.removeWorkspaceGitWatchTarget(cwd),
});
this.agentLoadingService =
agentLoadingService ??
new AgentLoadingService({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
this.createAgentMcpTransport = createAgentMcpTransport;
this.terminalManager = terminalManager;
if (this.terminalManager) {
@@ -697,11 +699,6 @@ export class Session {
this.getSpeechReadiness = dictation?.getSpeechReadiness;
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.abortController = new AbortController();
this.sessionLogger = logger.child({
module: "session",
clientId: this.clientId,
sessionId: this.sessionId,
});
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
runtimeSettings: this.agentProviderRuntimeSettings,
});
@@ -992,7 +989,6 @@ export class Session {
event: serializedEvent,
timestamp: new Date().toISOString(),
...(typeof event.seq === "number" ? { seq: event.seq } : {}),
...(typeof event.epoch === "string" ? { epoch: event.epoch } : {}),
} as const;
this.emit({
@@ -1096,58 +1092,7 @@ export class Session {
}
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
const existing = this.agentManager.getAgent(agentId);
if (existing) {
return existing;
}
const inflight = pendingAgentInitializations.get(agentId);
if (inflight) {
return inflight;
}
const initPromise = (async () => {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
let snapshot: ManagedAgent;
if (handle) {
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent resumed from persistence",
);
} else {
const config = buildSessionConfig(record);
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels });
this.sessionLogger.info(
{ agentId, provider: record.provider },
"Agent created from stored config",
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
return this.agentManager.getAgent(agentId) ?? snapshot;
})();
pendingAgentInitializations.set(agentId, initPromise);
try {
return await initPromise;
} finally {
const current = pendingAgentInitializations.get(agentId);
if (current === initPromise) {
pendingAgentInitializations.delete(agentId);
}
}
return this.agentLoadingService.ensureAgentLoaded({ agentId });
}
private matchesAgentFilter(options: {
@@ -1266,155 +1211,15 @@ export class Session {
});
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
workspace: PersistedWorkspaceRecord;
changed: boolean;
}> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = new Date().toISOString();
const nextProjectCreatedAt = existing?.createdAt ?? now;
const nextWorkspaceCreatedAt = existing?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existing ||
existing.archivedAt ||
existing.projectId !== nextWorkspaceRecord.projectId ||
existing.kind !== nextWorkspaceRecord.kind ||
existing.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existing!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) {
await this.archiveProjectRecordIfEmpty(existing.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
return this.workspaceReconciliationService.reconcileWorkspaceRecord(workspaceId);
}
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
},
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
return this.workspaceReconciliationService.reconcileActiveWorkspaceRecords();
}
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
@@ -1880,8 +1685,8 @@ export class Session {
(await this.agentStorage.get(agentId))?.cwd ??
null;
// Prevent the persistence hook from re-creating the record while we close/delete.
this.agentStorage.beginDelete(agentId);
// File-backed storage still needs an early delete fence before closeAgent().
beginAgentDeleteIfSupported(this.agentStorage, agentId);
try {
await this.agentManager.closeAgent(agentId);
@@ -1894,10 +1699,11 @@ export class Session {
try {
await this.agentStorage.remove(agentId);
await this.agentManager.deleteCommittedTimeline(agentId);
} catch (error: any) {
this.sessionLogger.error(
{ err: error, agentId },
`Failed to remove agent ${agentId} from registry`,
`Failed to fully delete agent ${agentId}`,
);
}
@@ -1946,37 +1752,7 @@ export class Session {
}
const archivedAt = new Date().toISOString();
const existing = await this.agentStorage.get(agentId);
let archivedRecord: StoredAgentRecord | null = existing;
if (!archivedRecord) {
const liveAgent = this.agentManager.getAgent(agentId);
if (!liveAgent) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.applySnapshot(liveAgent, {
internal: liveAgent.internal,
});
archivedRecord = await this.agentStorage.get(agentId);
if (!archivedRecord) {
throw new Error(`Agent not found in storage after snapshot: ${agentId}`);
}
}
const normalizedStatus =
archivedRecord.lastStatus === "running" || archivedRecord.lastStatus === "initializing"
? "idle"
: archivedRecord.lastStatus;
const nextRecord: StoredAgentRecord = {
...archivedRecord,
archivedAt,
lastStatus: normalizedStatus,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
};
await this.agentStorage.upsert(nextRecord);
const nextRecord = await this.agentManager.archiveSnapshot(agentId, archivedAt);
// Unload the agent from memory — the storage record is the source of truth now.
// This tears down the provider session and drops the hydrated timeline,
@@ -1993,29 +1769,11 @@ export class Session {
}
private async unarchiveAgentState(agentId: string): Promise<boolean> {
const record = await this.agentStorage.get(agentId);
if (!record || !record.archivedAt) {
return false;
}
await this.agentStorage.upsert({
...record,
archivedAt: null,
});
this.agentManager.notifyAgentState(agentId);
return true;
return this.agentManager.unarchiveSnapshot(agentId);
}
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
const records = await this.agentStorage.list();
const matched = records.find(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
);
if (!matched) {
return;
}
await this.unarchiveAgentState(matched.id);
await this.agentManager.unarchiveSnapshotByHandle(handle);
}
private async handleUpdateAgentRequest(
@@ -2051,26 +1809,10 @@ export class Session {
}
try {
const liveAgent = this.agentManager.getAgent(agentId);
if (liveAgent) {
if (normalizedName) {
await this.agentManager.setTitle(agentId, normalizedName);
}
if (normalizedLabels) {
await this.agentManager.setLabels(agentId, normalizedLabels);
}
} else {
const existing = await this.agentStorage.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await this.agentStorage.upsert({
...existing,
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: { ...existing.labels, ...normalizedLabels } } : {}),
});
}
await this.agentManager.updateAgentMetadata(agentId, {
...(normalizedName ? { title: normalizedName } : {}),
...(normalizedLabels ? { labels: normalizedLabels } : {}),
});
this.emit({
type: "update_agent_response",
@@ -2750,9 +2492,11 @@ export class Session {
);
try {
await this.unarchiveAgentByHandle(handle);
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides);
const snapshot = await this.agentLoadingService.resumeAgent({
handle,
overrides,
});
await this.unarchiveAgentState(snapshot.id);
await this.agentManager.hydrateTimelineFromProvider(snapshot.id);
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(snapshot.id).length;
if (requestId) {
@@ -2793,32 +2537,10 @@ export class Session {
try {
await this.unarchiveAgentState(agentId);
let snapshot: ManagedAgent;
const existing = this.agentManager.getAgent(agentId);
if (existing) {
if (this.agentManager.getAgent(agentId)) {
await this.interruptAgentIfRunning(agentId);
if (existing.persistence) {
snapshot = await this.agentManager.reloadAgentSession(agentId);
} else {
snapshot = existing;
}
} else {
const record = await this.agentStorage.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence);
if (!handle) {
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`);
}
snapshot = await this.agentManager.resumeAgentFromPersistence(
handle,
buildConfigOverrides(record),
agentId,
extractTimestamps(record),
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
const snapshot = await this.agentLoadingService.refreshAgent({ agentId });
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) {
@@ -5907,69 +5629,11 @@ export class Session {
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = new Date().toISOString();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
return this.workspaceReconciliationService.registerPendingWorktreeWorkspace(options);
}
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existing = await this.workspaceRegistry.get(workspaceId);
if (!existing || existing.archivedAt) {
this.removeWorkspaceGitWatchTarget(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
this.removeWorkspaceGitWatchTarget(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existing.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existing.projectId, nextArchivedAt);
}
await this.workspaceReconciliationService.archiveWorkspaceRecord(workspaceId, archivedAt);
}
private async emitWorkspaceUpdateForCwd(
@@ -6409,17 +6073,10 @@ export class Session {
msg: Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
const projection: TimelineProjectionMode = msg.projection ?? "projected";
const requestedLimit = msg.limit;
const limit = requestedLimit ?? (direction === "after" ? 0 : undefined);
const shouldLimitByProjectedWindow =
projection === "canonical" &&
direction === "tail" &&
typeof requestedLimit === "number" &&
requestedLimit > 0;
const cursor: AgentTimelineCursor | undefined = msg.cursor
? {
epoch: msg.cursor.epoch,
seq: msg.cursor.seq,
}
: undefined;
@@ -6427,87 +6084,19 @@ export class Session {
try {
const snapshot = await this.ensureAgentLoaded(msg.agentId);
const agentPayload = await this.buildAgentPayload(snapshot);
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
const timeline = await this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit:
shouldLimitByProjectedWindow && typeof requestedLimit === "number"
? Math.max(1, Math.floor(requestedLimit))
: limit,
limit,
});
let hasOlder = timeline.hasOlder;
let hasNewer = timeline.hasNewer;
let startCursor: { epoch: string; seq: number } | null = null;
let endCursor: { epoch: string; seq: number } | null = null;
let entries: ReturnType<typeof projectTimelineRows>;
if (shouldLimitByProjectedWindow) {
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
let fetchLimit = projectedLimit;
let projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
while (timeline.hasOlder) {
const needsMoreProjectedEntries =
projectedWindow.projectedEntries.length < projectedLimit;
const firstLoadedRow = timeline.rows[0];
const firstSelectedRow = projectedWindow.selectedRows[0];
const startsAtLoadedBoundary =
firstLoadedRow != null &&
firstSelectedRow != null &&
firstSelectedRow.seq === firstLoadedRow.seq;
const boundaryIsAssistantChunk =
startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message";
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
break;
}
const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1);
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2);
if (nextFetchLimit <= fetchLimit) {
break;
}
fetchLimit = nextFetchLimit;
timeline = this.agentManager.fetchTimeline(msg.agentId, {
direction,
cursor,
limit: fetchLimit,
});
projectedWindow = selectTimelineWindowByProjectedLimit({
rows: timeline.rows,
provider: snapshot.provider,
direction,
limit: projectedLimit,
collapseToolLifecycle: false,
});
}
const selectedRows = projectedWindow.selectedRows;
entries = projectTimelineRows(selectedRows, snapshot.provider, projection);
if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq };
endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq };
hasOlder = projectedWindow.minSeq > timeline.window.minSeq;
hasNewer = false;
}
} else {
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
}
const firstRow = timeline.rows[0];
const lastRow = timeline.rows[timeline.rows.length - 1];
const entries = timeline.rows.map((row) => ({
provider: snapshot.provider,
item: row.item,
timestamp: row.timestamp,
seq: row.seq,
}));
this.emit({
type: "fetch_agent_timeline_response",
@@ -6516,16 +6105,10 @@ export class Session {
agentId: msg.agentId,
agent: agentPayload,
direction,
projection,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
window: timeline.window,
startCursor,
endCursor,
hasOlder,
hasNewer,
startSeq: firstRow?.seq ?? null,
endSeq: lastRow?.seq ?? null,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
entries,
error: null,
},
@@ -6542,14 +6125,8 @@ export class Session {
agentId: msg.agentId,
agent: null,
direction,
projection,
epoch: "",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
startCursor: null,
endCursor: null,
startSeq: null,
endSeq: null,
hasOlder: false,
hasNewer: false,
entries: [],
@@ -170,7 +170,8 @@ describe("workspace git watch targets", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
sessionAny.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set();
let descriptor = {
id: "/tmp/repo",
@@ -0,0 +1,201 @@
import { describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createSessionForOwnershipTests(options?: { workspaceReconciliationService?: any }) {
const emitted: Array<{ type: string; payload: unknown }> = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage: (message) => emitted.push(message as any),
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async (projectId: string) =>
createPersistedProjectRecord({
projectId,
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {
throw new Error("not used");
},
archive: async () => {
throw new Error("not used");
},
remove: async () => {
throw new Error("not used");
},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
workspaceReconciliationService: options?.workspaceReconciliationService,
}) as any;
return { session, emitted };
}
describe("workspace reconciliation ownership", () => {
test("open_project_request delegates registration to WorkspaceReconciliationService", async () => {
const reconcileWorkspaceRecord = vi.fn(async () => ({
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
changed: true,
}));
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord,
reconcileActiveWorkspaceRecords: async () => new Set<string>(),
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.buildProjectPlacement = async (cwd: string) => ({
projectKey: cwd,
projectName: "repo",
checkout: {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
},
});
session.emitWorkspaceUpdateForCwd = vi.fn(async () => {});
await session.handleMessage({
type: "open_project_request",
cwd: "/tmp/repo",
requestId: "req-open",
});
expect(reconcileWorkspaceRecord).toHaveBeenCalledWith("/tmp/repo");
expect(emitted).toContainEqual({
type: "open_project_response",
payload: expect.objectContaining({
requestId: "req-open",
error: null,
workspace: expect.objectContaining({
id: "/tmp/repo",
}),
}),
});
});
test("workspace update fanout delegates sweep to WorkspaceReconciliationService", async () => {
const reconcileActiveWorkspaceRecords = vi.fn(
async () => new Set(["/tmp/repo", "/tmp/repo/worktree"]),
);
const { session, emitted } = createSessionForOwnershipTests({
workspaceReconciliationService: {
reconcileWorkspaceRecord: async () => {
throw new Error("not used");
},
reconcileActiveWorkspaceRecords,
registerPendingWorktreeWorkspace: async () => {
throw new Error("not used");
},
archiveWorkspaceRecord: async () => {},
},
});
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-ownership",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.listWorkspaceDescriptorsSnapshot = async () => [
{
id: "/tmp/repo",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "non_git",
workspaceKind: "directory",
name: "repo",
status: "done",
activityAt: null,
},
{
id: "/tmp/repo/worktree",
projectId: "/tmp/repo",
projectDisplayName: "repo",
projectRootPath: "/tmp/repo",
projectKind: "git",
workspaceKind: "worktree",
name: "feature-a",
status: "running",
activityAt: "2026-03-01T12:00:00.000Z",
},
];
await session.emitWorkspaceUpdateForCwd("/tmp/repo/worktree");
expect(reconcileActiveWorkspaceRecords).toHaveBeenCalledTimes(1);
const workspaceUpdates = emitted.filter((message) => message.type === "workspace_update") as
| Array<{ type: "workspace_update"; payload: any }>;
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
"/tmp/repo",
"/tmp/repo/worktree",
]);
});
});
@@ -292,7 +292,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () => new Set();
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () => new Set();
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -411,7 +411,7 @@ describe("workspace aggregation", () => {
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
};
session.reconcileActiveWorkspaceRecords = async () =>
session.workspaceReconciliationService.reconcileActiveWorkspaceRecords = async () =>
new Set(["/tmp/repo", "/tmp/repo/worktree"]);
session.listWorkspaceDescriptorsSnapshot = async () => [
{
@@ -0,0 +1,156 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Session } from "./session.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
describe("snapshot mutation ownership boundary", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("daemon live mutations write one durable snapshot through the manager-owned path", async () => {
const daemonHandle = await createTestPaseoDaemon();
const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-"));
try {
const snapshot = await daemonHandle.daemon.agentManager.createAgent({
provider: "codex",
cwd,
model: "gpt-5.2-codex",
});
await daemonHandle.daemon.agentManager.flush();
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy).toHaveBeenCalledTimes(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();
}
});
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
const onMessage = vi.fn();
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
id: "agent-1",
provider: "codex",
cwd: "/tmp/project",
createdAt: "2026-03-24T00:00:00.000Z",
updatedAt: archivedAt,
title: null,
labels: {},
lastStatus: "idle" as const,
config: null,
persistence: null,
archivedAt,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
}));
const unarchiveSnapshot = vi.fn(async () => true);
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
const updateAgentMetadata = vi.fn(async () => undefined);
const directStorageWrite = vi.fn(async () => {
throw new Error("Session should not write snapshots directly");
});
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test-client",
onMessage,
logger: logger as any,
downloadTokenStore: {} as any,
pushTokenStore: {} as any,
paseoHome: "/tmp/paseo-test",
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveSnapshot,
unarchiveSnapshot,
unarchiveSnapshotByHandle,
updateAgentMetadata,
} as any,
agentStorage: {
list: async () => [],
get: async () => null,
applySnapshot: directStorageWrite,
upsert: directStorageWrite,
} as any,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
} as any,
createAgentMcpTransport: async () => {
throw new Error("not used");
},
stt: null,
tts: null,
terminalManager: null,
}) as any;
const archiveResult = await session.archiveAgentState("agent-1");
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
expect(archiveResult.archivedAt).toBeTruthy();
await session.unarchiveAgentState("agent-1");
expect(unarchiveSnapshot).toHaveBeenCalledWith("agent-1");
const handle = { provider: "codex", sessionId: "session-1" };
await session.unarchiveAgentByHandle(handle);
expect(unarchiveSnapshotByHandle).toHaveBeenCalledWith(handle);
await session.handleUpdateAgentRequest(
"agent-1",
"Renamed agent",
{ lane: "phase-1a" },
"req-1",
);
expect(updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Renamed agent",
labels: { lane: "phase-1a" },
});
expect(onMessage).toHaveBeenCalledWith({
type: "update_agent_response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
},
});
expect(directStorageWrite).not.toHaveBeenCalled();
});
});
@@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
const agentManager = {
setAgentAttentionCallback: vi.fn(),
getAgent: vi.fn(() => null),
getLastAssistantMessage: vi.fn(async () => null),
...agentManagerOverrides,
};
@@ -92,22 +93,20 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
vi.clearAllMocks();
});
it("uses assistant preview text for push notifications with markdown removed", () => {
it("uses assistant preview text for push notifications with markdown removed", async () => {
const getLastAssistantMessage = vi.fn(
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
);
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
timeline: [
{
type: "assistant_message",
text: "**Done**. Updated `README.md` and [link](https://example.com).",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-1",
provider: "claude",
reason: "finished",
@@ -122,30 +121,28 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
reason: "finished",
},
});
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
});
it("sends push notifications regardless of UI label presence", () => {
it("sends push notifications regardless of UI label presence", async () => {
const getLastAssistantMessage = vi.fn(async () => "Done.");
const { server } = createServer({
getAgent: vi.fn(() => ({
config: { title: null },
cwd: "/tmp/worktree",
labels: {},
timeline: [
{
type: "assistant_message",
text: "Done.",
},
],
pendingPermissions: new Map(),
})),
getLastAssistantMessage,
});
(server as any).broadcastAgentAttention({
await (server as any).broadcastAgentAttention({
agentId: "agent-2",
provider: "claude",
reason: "finished",
});
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
});
});
+10 -8
View File
@@ -4,7 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
import { join } from "path";
import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
@@ -42,7 +42,6 @@ import {
} from "./agent-attention-policy.js";
import {
buildAgentAttentionNotificationPayload,
findLatestAssistantMessageFromTimeline,
findLatestPermissionRequest,
} from "../shared/agent-attention-notification.js";
@@ -224,7 +223,7 @@ export class VoiceAssistantWebSocketServer {
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly agentStorage: AgentSnapshotStore;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly downloadTokenStore: DownloadTokenStore;
@@ -282,7 +281,7 @@ export class VoiceAssistantWebSocketServer {
logger: pino.Logger,
serverId: string,
agentManager: AgentManager,
agentStorage: AgentStorage,
agentStorage: AgentSnapshotStore,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
createAgentMcpTransport: AgentMcpTransportFactory,
@@ -343,7 +342,9 @@ export class VoiceAssistantWebSocketServer {
this.pushService = new PushService(pushLogger, this.pushTokenStore);
this.agentManager.setAgentAttentionCallback((params) => {
this.broadcastAgentAttention(params);
void this.broadcastAgentAttention(params).catch((err) => {
this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention");
});
});
const { allowedOrigins, allowedHosts } = wsConfig;
@@ -1240,11 +1241,11 @@ export class VoiceAssistantWebSocketServer {
};
}
private broadcastAgentAttention(params: {
private async broadcastAgentAttention(params: {
agentId: string;
provider: AgentProvider;
reason: "finished" | "error" | "permission";
}): void {
}): Promise<void> {
const clientEntries: Array<{
ws: WebSocketLike;
state: ClientAttentionState;
@@ -1259,11 +1260,12 @@ export class VoiceAssistantWebSocketServer {
const allStates = clientEntries.map((e) => e.state);
const agent = this.agentManager.getAgent(params.agentId);
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
const notification = buildAgentAttentionNotificationPayload({
reason: params.reason,
serverId: this.serverId,
agentId: params.agentId,
assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null,
assistantMessage,
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
});
@@ -0,0 +1,413 @@
import { describe, expect, test, vi } from "vitest";
import type { ProjectPlacementPayload } from "./messages.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
function createProjectPlacement(input: {
cwd: string;
projectId: string;
projectName: string;
isGit: boolean;
branchName?: string | null;
remoteUrl?: string | null;
isPaseoOwnedWorktree?: boolean;
mainRepoRoot?: string | null;
}): ProjectPlacementPayload {
return {
projectKey: input.projectId,
projectName: input.projectName,
checkout: {
cwd: input.cwd,
isGit: input.isGit,
currentBranch: input.branchName ?? null,
remoteUrl: input.remoteUrl ?? null,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree ?? false,
mainRepoRoot: input.mainRepoRoot ?? null,
},
};
}
function createInMemoryRegistries() {
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
return {
projects,
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId: string) => projects.get(projectId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedProjectRecord>) => {
projects.set(record.projectId, record);
},
archive: async (projectId: string, archivedAt: string) => {
const existing = projects.get(projectId);
if (!existing) {
return;
}
projects.set(projectId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (projectId: string) => {
projects.delete(projectId);
},
},
workspaces,
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
upsert: async (record: ReturnType<typeof createPersistedWorkspaceRecord>) => {
workspaces.set(record.workspaceId, record);
},
archive: async (workspaceId: string, archivedAt: string) => {
const existing = workspaces.get(workspaceId);
if (!existing) {
return;
}
workspaces.set(workspaceId, {
...existing,
archivedAt,
updatedAt: archivedAt,
});
},
remove: async (workspaceId: string) => {
workspaces.delete(workspaceId);
},
},
};
}
describe("WorkspaceReconciliationService", () => {
test("reconcileWorkspaceRecord registers a new workspace and project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: "repo",
isGit: false,
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T00:00:00.000Z",
});
const result = await service.reconcileWorkspaceRecord("/tmp/repo");
expect(result).toEqual({
changed: true,
workspace: createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo",
projectId: "/tmp/repo",
cwd: "/tmp/repo",
kind: "directory",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
});
expect(projects.get("/tmp/repo")).toEqual(
createPersistedProjectRecord({
projectId: "/tmp/repo",
rootPath: "/tmp/repo",
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-25T00:00:00.000Z",
updatedAt: "2026-03-25T00:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo")).toEqual(result.workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith("/tmp/repo", {
isGit: false,
});
});
test("registerPendingWorktreeWorkspace preregisters a git worktree under the repo project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const syncWorkspaceGitWatchTarget = vi.fn(async () => {});
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: "remote:github.com/acme/repo",
projectName: "acme/repo",
isGit: true,
branchName: "main",
remoteUrl: "https://github.com/acme/repo.git",
}),
syncWorkspaceGitWatchTarget,
removeWorkspaceGitWatchTarget: vi.fn(),
now: () => "2026-03-25T01:00:00.000Z",
});
const workspace = await service.registerPendingWorktreeWorkspace({
repoRoot: "/tmp/repo",
worktreePath: "/tmp/repo/.paseo/worktrees/feature-a",
branchName: "feature-a",
});
expect(workspace).toEqual(
createPersistedWorkspaceRecord({
workspaceId: "/tmp/repo/.paseo/worktrees/feature-a",
projectId: "remote:github.com/acme/repo",
cwd: "/tmp/repo/.paseo/worktrees/feature-a",
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(projects.get("remote:github.com/acme/repo")).toEqual(
createPersistedProjectRecord({
projectId: "remote:github.com/acme/repo",
rootPath: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
createdAt: "2026-03-25T01:00:00.000Z",
updatedAt: "2026-03-25T01:00:00.000Z",
}),
);
expect(workspaces.get("/tmp/repo/.paseo/worktrees/feature-a")).toEqual(workspace);
expect(syncWorkspaceGitWatchTarget).toHaveBeenCalledWith(
"/tmp/repo/.paseo/worktrees/feature-a",
{
isGit: true,
},
);
});
test("reconcileActiveWorkspaceRecords reassigns drifted workspaces and archives the old empty project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const localProjectId = "/tmp/repo";
const remoteProjectId = "remote:github.com/acme/repo";
const mainWorkspaceId = "/tmp/repo";
const worktreeWorkspaceId = "/tmp/repo/.paseo/worktrees/feature-a";
projects.set(
localProjectId,
createPersistedProjectRecord({
projectId: localProjectId,
rootPath: mainWorkspaceId,
kind: "git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
mainWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: mainWorkspaceId,
projectId: localProjectId,
cwd: mainWorkspaceId,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
worktreeWorkspaceId,
createPersistedWorkspaceRecord({
workspaceId: worktreeWorkspaceId,
projectId: localProjectId,
cwd: worktreeWorkspaceId,
kind: "worktree",
displayName: "feature-a",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: remoteProjectId,
projectName: "acme/repo",
isGit: true,
branchName: cwd === mainWorkspaceId ? "main" : "feature-a",
remoteUrl: "https://github.com/acme/repo.git",
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async () => true,
now: () => "2026-03-25T02:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
mainWorkspaceId,
worktreeWorkspaceId,
]);
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId);
expect(projects.get(localProjectId)?.archivedAt).toBe("2026-03-25T02:00:00.000Z");
});
test("reconcileActiveWorkspaceRecords archives missing-dir and all-archived-agent workspaces but keeps no-agent and active-agent cases", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const activeWorkspaceId = "/tmp/active";
const noAgentWorkspaceId = "/tmp/no-agents";
const missingWorkspaceId = "/tmp/missing";
const archivedAgentsWorkspaceId = "/tmp/all-archived";
for (const workspaceId of [
activeWorkspaceId,
noAgentWorkspaceId,
missingWorkspaceId,
archivedAgentsWorkspaceId,
]) {
projects.set(
workspaceId,
createPersistedProjectRecord({
projectId: workspaceId,
rootPath: workspaceId,
kind: "non_git",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId: workspaceId,
cwd: workspaceId,
kind: "directory",
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
}
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [
{
id: "archived-agent",
cwd: archivedAgentsWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "inactive-agent",
cwd: activeWorkspaceId,
archivedAt: "2026-03-02T00:00:00.000Z",
},
{
id: "active-agent",
cwd: activeWorkspaceId,
archivedAt: null,
},
],
} as any,
buildProjectPlacement: async (cwd: string) =>
createProjectPlacement({
cwd,
projectId: cwd,
projectName: cwd.split("/").at(-1) ?? cwd,
isGit: false,
}),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget: vi.fn(),
checkDirectoryExists: async (cwd: string) => cwd !== missingWorkspaceId,
now: () => "2026-03-25T03:00:00.000Z",
});
const changedWorkspaceIds = await service.reconcileActiveWorkspaceRecords();
expect(Array.from(changedWorkspaceIds).sort()).toEqual([
archivedAgentsWorkspaceId,
missingWorkspaceId,
]);
expect(workspaces.get(missingWorkspaceId)?.archivedAt).toBe("2026-03-25T03:00:00.000Z");
expect(workspaces.get(archivedAgentsWorkspaceId)?.archivedAt).toBe(
"2026-03-25T03:00:00.000Z",
);
expect(workspaces.get(noAgentWorkspaceId)?.archivedAt).toBeNull();
expect(workspaces.get(activeWorkspaceId)?.archivedAt).toBeNull();
});
test("archiveWorkspaceRecord archives the last active workspace and its project", async () => {
const { projects, projectRegistry, workspaces, workspaceRegistry } = createInMemoryRegistries();
const removeWorkspaceGitWatchTarget = vi.fn();
const projectId = "/tmp/repo";
const workspaceId = "/tmp/repo";
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: workspaceId,
kind: "non_git",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
workspaces.set(
workspaceId,
createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: workspaceId,
kind: "directory",
displayName: "repo",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
);
const service = new WorkspaceReconciliationService({
projectRegistry: projectRegistry as any,
workspaceRegistry: workspaceRegistry as any,
agentStorage: {
list: async () => [],
} as any,
buildProjectPlacement: vi.fn(),
syncWorkspaceGitWatchTarget: vi.fn(async () => {}),
removeWorkspaceGitWatchTarget,
now: () => "2026-03-25T04:00:00.000Z",
});
await service.archiveWorkspaceRecord(workspaceId);
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(projects.get(projectId)?.archivedAt).toBe("2026-03-25T04:00:00.000Z");
expect(removeWorkspaceGitWatchTarget).toHaveBeenCalledWith(workspaceId);
});
});
@@ -0,0 +1,296 @@
import { stat } from "node:fs/promises";
import type { ProjectPlacementPayload } from "./messages.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import {
detectStaleWorkspaces,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId as normalizePersistedWorkspaceId,
} from "./workspace-registry-model.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
} from "./workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
type Awaitable<T> = T | Promise<T>;
export type WorkspaceReconciliationServiceOptions = {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
agentStorage: Pick<AgentSnapshotStore, "list">;
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
syncWorkspaceGitWatchTarget?: (
cwd: string,
options: { isGit: boolean },
) => Awaitable<void>;
removeWorkspaceGitWatchTarget?: (cwd: string) => Awaitable<void>;
checkDirectoryExists?: (cwd: string) => Promise<boolean>;
now?: () => string;
};
export type ReconcileWorkspaceRecordResult = {
workspace: PersistedWorkspaceRecord;
changed: boolean;
};
export class WorkspaceReconciliationService {
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly agentStorage: Pick<AgentSnapshotStore, "list">;
private readonly buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
private readonly syncWorkspaceGitWatchTarget: (
cwd: string,
options: { isGit: boolean },
) => Promise<void>;
private readonly removeWorkspaceGitWatchTarget: (cwd: string) => Promise<void>;
private readonly checkDirectoryExists: (cwd: string) => Promise<boolean>;
private readonly now: () => string;
constructor(options: WorkspaceReconciliationServiceOptions) {
this.projectRegistry = options.projectRegistry;
this.workspaceRegistry = options.workspaceRegistry;
this.agentStorage = options.agentStorage;
this.buildProjectPlacement = options.buildProjectPlacement;
this.syncWorkspaceGitWatchTarget = async (cwd, syncOptions) => {
await options.syncWorkspaceGitWatchTarget?.(cwd, syncOptions);
};
this.removeWorkspaceGitWatchTarget = async (cwd) => {
await options.removeWorkspaceGitWatchTarget?.(cwd);
};
this.checkDirectoryExists =
options.checkDirectoryExists ??
(async (cwd) => {
try {
await stat(cwd);
return true;
} catch {
return false;
}
});
this.now = options.now ?? (() => new Date().toISOString());
}
async reconcileWorkspaceRecord(workspaceId: string): Promise<ReconcileWorkspaceRecordResult> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
const placement = await this.buildProjectPlacement(normalizedWorkspaceId);
await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, {
isGit: placement.checkout.isGit,
});
const now = this.now();
const nextWorkspaceCreatedAt = existingWorkspace?.createdAt ?? now;
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: currentProjectRecord?.createdAt ?? nextWorkspaceCreatedAt,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId: normalizedWorkspaceId,
placement,
createdAt: nextWorkspaceCreatedAt,
updatedAt: now,
});
const needsWorkspaceUpdate =
!existingWorkspace ||
existingWorkspace.archivedAt ||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId ||
existingWorkspace.kind !== nextWorkspaceRecord.kind ||
existingWorkspace.displayName !== nextWorkspaceRecord.displayName;
const needsProjectUpdate =
!currentProjectRecord ||
currentProjectRecord.archivedAt ||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
currentProjectRecord.kind !== nextProjectRecord.kind ||
currentProjectRecord.displayName !== nextProjectRecord.displayName;
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
return {
workspace: existingWorkspace!,
changed: false,
};
}
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return {
workspace: nextWorkspaceRecord,
changed: true,
};
}
async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
const changedWorkspaceIds = new Set<string>();
const activeWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => !workspace.archivedAt,
);
const staleWorkspaceIds = await detectStaleWorkspaces({
activeWorkspaces,
agentRecords: (await this.agentStorage.list()).map((agent) => ({
cwd: agent.cwd,
archivedAt: agent.archivedAt ?? null,
})),
checkDirectoryExists: this.checkDirectoryExists,
});
for (const workspaceId of staleWorkspaceIds) {
await this.archiveWorkspaceRecord(workspaceId);
changedWorkspaceIds.add(workspaceId);
}
for (const workspace of activeWorkspaces) {
if (staleWorkspaceIds.has(workspace.workspaceId)) {
continue;
}
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId);
if (result.changed) {
changedWorkspaceIds.add(result.workspace.workspaceId);
}
}
return changedWorkspaceIds;
}
async registerPendingWorktreeWorkspace(options: {
repoRoot: string;
worktreePath: string;
branchName: string;
}): Promise<PersistedWorkspaceRecord> {
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await this.buildProjectPlacement(options.repoRoot);
const placement: ProjectPlacementPayload = {
...basePlacement,
checkout: {
cwd: workspaceId,
isGit: true,
currentBranch: options.branchName,
remoteUrl: basePlacement.checkout.remoteUrl,
isPaseoOwnedWorktree: true,
mainRepoRoot: options.repoRoot,
},
};
const now = this.now();
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
const existingProject = await this.projectRegistry.get(placement.projectKey);
const nextProjectRecord = this.buildPersistedProjectRecord({
workspaceId,
placement,
createdAt: existingProject?.createdAt ?? now,
updatedAt: now,
});
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
workspaceId,
placement,
createdAt: existingWorkspace?.createdAt ?? now,
updatedAt: now,
});
await this.projectRegistry.upsert(nextProjectRecord);
await this.workspaceRegistry.upsert(nextWorkspaceRecord);
await this.syncWorkspaceGitWatchTarget(workspaceId, {
isGit: placement.checkout.isGit,
});
if (
existingWorkspace &&
!existingWorkspace.archivedAt &&
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
) {
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return nextWorkspaceRecord;
}
async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId);
const existingWorkspace = await this.workspaceRegistry.get(normalizedWorkspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
return;
}
const nextArchivedAt = archivedAt ?? this.now();
await this.workspaceRegistry.archive(normalizedWorkspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(normalizedWorkspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
}
}
private buildPersistedProjectRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedProjectRecord {
return createPersistedProjectRecord({
projectId: input.placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
kind: deriveProjectKind(input.placement.checkout),
displayName: input.placement.projectName,
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private buildPersistedWorkspaceRecord(input: {
workspaceId: string;
placement: ProjectPlacementPayload;
createdAt: string;
updatedAt: string;
}): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
workspaceId: input.workspaceId,
projectId: input.placement.projectKey,
cwd: input.workspaceId,
kind: deriveWorkspaceKind(input.placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: input.workspaceId,
checkout: input.placement.checkout,
}),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
archivedAt: null,
});
}
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === projectId && !workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
await this.projectRegistry.archive(projectId, archivedAt);
}
}
}
@@ -1,13 +1,19 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import {
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "./workspace-registry.test-helpers.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { openPaseoDatabase } from "./db/pglite-database.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
describe("bootstrapWorkspaceRegistries", () => {
let tmpDir: string;
@@ -164,4 +170,66 @@ describe("bootstrapWorkspaceRegistries", () => {
expect(await workspaceRegistry.list()).toHaveLength(1);
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
});
test("materializes into DB-backed registries when the database is empty", async () => {
mkdirSync(paseoHome, { recursive: true });
const database = await openPaseoDatabase(path.join(paseoHome, "db"));
const dbProjectRegistry = new DbProjectRegistry(database.db);
const dbWorkspaceRegistry = new DbWorkspaceRegistry(database.db);
try {
await agentStorage.initialize();
await agentStorage.upsert({
id: "agent-1",
provider: "codex",
cwd: "/tmp/non-git-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,
});
await bootstrapWorkspaceRegistries({
paseoHome,
agentStorage,
projectRegistry: dbProjectRegistry,
workspaceRegistry: dbWorkspaceRegistry,
logger,
});
expect(await dbProjectRegistry.list()).toEqual([
{
projectId: "/tmp/non-git-project",
rootPath: "/tmp/non-git-project",
kind: "non_git",
displayName: "non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
expect(await dbWorkspaceRegistry.list()).toEqual([
{
workspaceId: "/tmp/non-git-project",
projectId: "/tmp/non-git-project",
cwd: "/tmp/non-git-project",
kind: "directory",
displayName: "non-git-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
]);
} finally {
await database.close();
}
});
});
@@ -3,7 +3,7 @@ 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 type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import {
buildProjectPlacementForCwd,
deriveProjectKind,
@@ -49,19 +49,19 @@ function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
agentStorage: Pick<AgentSnapshotStore, "list">;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
}): Promise<void> {
const [projectsExists, workspacesExists] = await Promise.all([
options.projectRegistry.existsOnDisk(),
options.workspaceRegistry.existsOnDisk(),
]);
await Promise.all([options.projectRegistry.initialize(), options.workspaceRegistry.initialize()]);
if (projectsExists && workspacesExists) {
const [existingProjects, existingWorkspaces] = await Promise.all([
options.projectRegistry.list(),
options.workspaceRegistry.list(),
]);
if (existingProjects.length > 0 || existingWorkspaces.length > 0) {
return;
}
@@ -92,21 +92,6 @@ export async function bootstrapWorkspaceRegistries(options: {
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,
@@ -128,6 +113,21 @@ export async function bootstrapWorkspaceRegistries(options: {
updatedAt: existingProjectRange.updatedAt ?? updatedAt,
}),
);
await options.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: workspaceId,
checkout: placement.checkout,
}),
createdAt,
updatedAt,
}),
);
}
options.logger.info(
@@ -0,0 +1,162 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import {
parsePersistedProjectRecords,
parsePersistedWorkspaceRecords,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<TRecord extends RegistryRecord> {
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) => string;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
constructor(options: {
filePath: string;
logger: Logger;
parseRecords: (input: unknown) => TRecord[];
getId: (record: TRecord) => string;
component: string;
}) {
this.filePath = options.filePath;
this.parseRecords = options.parseRecords;
this.parseRecord = (record) => options.parseRecords([record])[0]!;
this.getId = options.getId;
this.logger = options.logger.child({
module: "workspace-registry",
component: options.component,
});
}
async initialize(): Promise<void> {
await this.load();
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath);
return true;
} catch {
return false;
}
}
async list(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(id) ?? null;
}
async upsert(record: TRecord): Promise<void> {
await this.load();
const parsed = this.parseRecord(record);
this.cache.set(this.getId(parsed), parsed);
await this.enqueuePersist();
}
async archive(id: string, archivedAt: string): Promise<void> {
await this.load();
const existing = this.cache.get(id);
if (!existing) {
return;
}
const next = this.parseRecord({
...existing,
updatedAt: archivedAt,
archivedAt,
});
this.cache.set(id, next);
await this.enqueuePersist();
}
async remove(id: string): Promise<void> {
await this.load();
if (!this.cache.delete(id)) {
return;
}
await this.enqueuePersist();
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.cache.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = this.parseRecords(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<void> {
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<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
parseRecords: parsePersistedProjectRecords,
getId: (record) => record.projectId,
component: "projects",
});
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
parseRecords: parsePersistedWorkspaceRecords,
getId: (record) => record.workspaceId,
component: "workspaces",
});
}
}
@@ -6,10 +6,12 @@ import { beforeEach, afterEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
FileBackedProjectRegistry,
FileBackedWorkspaceRegistry,
} from "./workspace-registry.test-helpers.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
describe("workspace registries", () => {
@@ -1,8 +1,3 @@
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";
@@ -31,6 +26,14 @@ const PersistedWorkspaceRecordSchema = z.object({
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
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<void>;
existsOnDisk(): Promise<boolean>;
@@ -51,152 +54,6 @@ export interface WorkspaceRegistry {
remove(workspaceId: string): Promise<void>;
}
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<TRecord extends RegistryRecord> {
private readonly filePath: string;
private readonly logger: Logger;
private readonly schema: z.ZodSchema<TRecord>;
private readonly getId: (record: TRecord) => string;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
constructor(options: {
filePath: string;
logger: Logger;
schema: z.ZodSchema<TRecord>;
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<void> {
await this.load();
}
async existsOnDisk(): Promise<boolean> {
try {
await fs.access(this.filePath);
return true;
} catch {
return false;
}
}
async list(): Promise<TRecord[]> {
await this.load();
return Array.from(this.cache.values());
}
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(id) ?? null;
}
async upsert(record: TRecord): Promise<void> {
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<void> {
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<void> {
await this.load();
if (!this.cache.delete(id)) {
return;
}
await this.enqueuePersist();
}
private async load(): Promise<void> {
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<void> {
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<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
}
export class FileBackedProjectRegistry
extends FileBackedRegistry<PersistedProjectRecord>
implements ProjectRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedProjectRecordSchema,
getId: (record) => record.projectId,
component: "projects",
});
}
}
export class FileBackedWorkspaceRegistry
extends FileBackedRegistry<PersistedWorkspaceRecord>
implements WorkspaceRegistry
{
constructor(filePath: string, logger: Logger) {
super({
filePath,
logger,
schema: PersistedWorkspaceRecordSchema,
getId: (record) => record.workspaceId,
component: "workspaces",
});
}
}
export function createPersistedProjectRecord(input: {
projectId: string;
rootPath: string;
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
AgentStreamMessageSchema,
FetchAgentTimelineRequestMessageSchema,
FetchAgentTimelineResponseMessageSchema,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
@@ -17,14 +18,8 @@ describe("shared messages stream parsing", () => {
agentId: "agent_live",
agent: null,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
startCursor: { epoch: "epoch-1", seq: 1 },
endCursor: { epoch: "epoch-1", seq: 2 },
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
entries: [
@@ -32,10 +27,7 @@ describe("shared messages stream parsing", () => {
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["assistant_merge"],
seq: 2,
},
],
error: null,
@@ -46,6 +38,51 @@ describe("shared messages stream parsing", () => {
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
});
it("rejects removed fetch timeline request baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({
type: "fetch_agent_timeline_request",
agentId: "agent_live",
requestId: "req-legacy",
direction: "after",
cursor: {
seq: 12,
epoch: "legacy-epoch",
},
projection: "canonical",
});
expect(parsed.success).toBe(false);
});
it("rejects removed fetch timeline response baggage at the parser boundary", () => {
const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({
type: "fetch_agent_timeline_response",
payload: {
requestId: "req-1",
agentId: "agent_live",
agent: null,
direction: "tail",
startSeq: 1,
endSeq: 2,
hasOlder: false,
hasNewer: false,
reset: false,
startCursor: { seq: 1 },
entries: [
{
provider: "codex",
item: { type: "assistant_message", text: "hello" },
timestamp: "2026-02-08T20:10:00.000Z",
seq: 2,
},
],
error: null,
},
});
expect(parsed.success).toBe(false);
});
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
const shutdownParsed = SessionInboundMessageSchema.safeParse({
type: "shutdown_server_request",
@@ -70,6 +107,7 @@ describe("shared messages stream parsing", () => {
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
seq: 12,
event: {
type: "timeline",
provider: "claude",
@@ -97,6 +135,27 @@ describe("shared messages stream parsing", () => {
}
});
it("rejects removed agent_stream baggage at the parser boundary", () => {
const parsed = AgentStreamMessageSchema.safeParse({
type: "agent_stream",
payload: {
agentId: "agent_live",
timestamp: "2026-02-08T20:10:00.000Z",
epoch: "legacy-epoch",
event: {
type: "timeline",
provider: "claude",
item: {
type: "assistant_message",
text: "hello",
},
},
},
});
expect(parsed.success).toBe(false);
});
it("parses representative sub_agent tool_call event", () => {
const parsed = AgentStreamMessageSchema.parse({
type: "agent_stream",
+57 -65
View File
@@ -722,22 +722,23 @@ export const ShutdownServerRequestMessageSchema = z.object({
requestId: z.string(),
});
export const AgentTimelineCursorSchema = z.object({
epoch: z.string(),
seq: z.number().int().nonnegative(),
});
export const AgentTimelineCursorSchema = z
.object({
seq: z.number().int().nonnegative(),
})
.strict();
export const FetchAgentTimelineRequestMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
// Default should be projected for app timeline loading.
projection: z.enum(["projected", "canonical"]).optional(),
});
export const FetchAgentTimelineRequestMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_request"),
agentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
// 0 means "all matching rows for this query window".
limit: z.number().int().nonnegative().optional(),
})
.strict();
export const SetAgentModeRequestMessageSchema = z.object({
type: z.literal("set_agent_mode_request"),
@@ -1545,17 +1546,20 @@ export const AgentUpdateMessageSchema = z.object({
]),
});
export const AgentStreamMessageSchema = z.object({
type: z.literal("agent_stream"),
payload: z.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present for timeline events. Maps 1:1 to canonical in-memory timeline rows.
seq: z.number().int().nonnegative().optional(),
epoch: z.string().optional(),
}),
});
export const AgentStreamMessageSchema = z
.object({
type: z.literal("agent_stream"),
payload: z
.object({
agentId: z.string(),
event: AgentStreamEventPayloadSchema,
timestamp: z.string(),
// Present only for committed timeline events.
seq: z.number().int().nonnegative().optional(),
})
.strict(),
})
.strict();
export const AgentStatusMessageSchema = z.object({
type: z.literal("agent_status"),
@@ -1649,46 +1653,34 @@ export const FetchAgentResponseMessageSchema = z.object({
}),
});
const AgentTimelineSeqRangeSchema = z.object({
startSeq: z.number().int().nonnegative(),
endSeq: z.number().int().nonnegative(),
});
export const AgentTimelineEntryPayloadSchema = z
.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
})
.strict();
export const AgentTimelineEntryPayloadSchema = z.object({
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seqStart: z.number().int().nonnegative(),
seqEnd: z.number().int().nonnegative(),
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
});
export const FetchAgentTimelineResponseMessageSchema = z.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
projection: z.enum(["projected", "canonical"]),
epoch: z.string(),
reset: z.boolean(),
staleCursor: z.boolean(),
gap: z.boolean(),
window: z.object({
minSeq: z.number().int().nonnegative(),
maxSeq: z.number().int().nonnegative(),
nextSeq: z.number().int().nonnegative(),
}),
startCursor: AgentTimelineCursorSchema.nullable(),
endCursor: AgentTimelineCursorSchema.nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
}),
});
export const FetchAgentTimelineResponseMessageSchema = z
.object({
type: z.literal("fetch_agent_timeline_response"),
payload: z
.object({
requestId: z.string(),
agentId: z.string(),
agent: AgentSnapshotPayloadSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
startSeq: z.number().int().nonnegative().nullable(),
endSeq: z.number().int().nonnegative().nullable(),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
entries: z.array(AgentTimelineEntryPayloadSchema),
error: z.string().nullable(),
})
.strict(),
})
.strict();
export const SendAgentMessageResponseMessageSchema = z.object({
type: z.literal("send_agent_message_response"),