Remove SQLite/Drizzle infrastructure from dev branch

Strip all database dependencies and revert to main's file-backed stores:
- Delete packages/server/src/server/db/ (schema, migrations, DB stores, legacy importers)
- Remove better-sqlite3, drizzle-orm, drizzle-kit, @electron/rebuild deps
- Revert bootstrap.ts to FileBackedProjectRegistry/WorkspaceRegistry/AgentStorage
- Restore workspace-registry-bootstrap.ts from main
- Revert AgentSnapshotStore → AgentStorage across all files
- Fix session.ts field names (directory→cwd, numeric id→string workspaceId)
- Remove DB-only tests from bootstrap.smoke.test, agent-manager.test, etc.
This commit is contained in:
Mohamed Boudra
2026-04-14 04:51:53 +07:00
parent e3962e1753
commit 4190a0aa72
46 changed files with 852 additions and 6259 deletions

View File

@@ -1,217 +0,0 @@
# Storage Revamp Plan
Status: active rollout, phases 1 and 2 complete
This document now tracks the storage revamp as it exists today, not as a speculative design exercise.
The DB foundation and the project/workspace identity cutover have landed. What remains is the explicit
creation/archive surface cleanup, timeline durability cutover, and final removal of legacy paths.
## Goals
- make structured records durable in Drizzle + SQLite
- make projects and workspaces explicit first-class records
- stop deriving project/workspace identity from agent `cwd`
- keep agent snapshot persistence behind clear ownership
- move committed timeline history to storage-owned rows
- remove legacy JSON and in-memory authority once the DB path is proven
## Out of scope
- moving config, keypairs, push tokens, or server identity into the DB
- persisting raw provider deltas or transport-only chunk streams
- designing a hosted/remote database story beyond keeping the schema portable
- durable reasoning history unless product explicitly asks for it later
## Current state
The storage revamp is no longer hypothetical.
Completed:
- Drizzle + SQLite database bootstrap is in place
- `projects`, `workspaces`, and `agent_snapshots` use integer primary keys
- `workspaces.project_id` and `agent_snapshots.workspace_id` cascade on delete
- `agent_snapshots.workspace_id` is `NOT NULL`
- legacy JSON import feeds the DB-backed structured records
- project/workspace records use explicit `directory` fields instead of path-as-identity
- session read paths now use persisted workspace/project rows instead of cwd/git derivation
- `workspace-reconciliation-service.ts` is deleted
- `workspace-registry-bootstrap.ts` is deleted
- `workspace-registry-model.ts` is reduced to `normalizeWorkspaceId`
Still pending:
- explicit `create_project` / `create_workspace` API cleanup
- final archive cascade behavior for descendants and live agents
- committed timeline storage cutover
- removal of remaining legacy JSON and in-memory committed-history authority
## Converged decisions
### Structured record authority
Projects, workspaces, and agent snapshots are DB-backed structured records.
The server should not recreate project/workspace identity from:
- git remotes
- worktree main-repo roots
- normalized cwd strings
Temporary exception:
- agent creation may still find-or-create a workspace by directory if the UI has not yet provided
`workspaceId` explicitly
That fallback is transitional and should be deleted once the client always sends the workspace id.
### Storage seams
The useful seams remain concrete and domain-shaped:
- `ProjectRegistry`
- `WorkspaceRegistry`
- `AgentSnapshotStore`
- `AgentTimelineStore`
There is no reason to reintroduce a reconciliation service layer for project/workspace identity.
### Timeline contract
The long-term timeline contract remains:
- committed rows are durable, canonical history
- provisional live updates are transient subscription state
- committed history is fetched by seq
- provider history replay is not the durability mechanism
The structured-record cutover is complete before the timeline cutover so timeline rows can rely on
stable DB-backed agent and workspace identity.
## Remaining phases
### Phase 3: Explicit creation and archive cleanup
Goal:
Remove the last transitional write paths that still infer state from directories.
Required work:
- add explicit `create_project` handling
- add explicit `create_workspace` handling
- make agent creation require `workspaceId` once the UI is ready
- finish archive semantics for workspaces/projects and any descendant agent state
- remove the temporary find-or-create-by-directory fallback from agent creation
Exit gate:
- project/workspace creation is explicit end to end
- no normal creation path infers identity from cwd or git metadata
- archive flows behave consistently for structured records and live runtime state
### Phase 4: Timeline storage cutover
Goal:
Make committed history durable and storage-owned.
Required work:
- make `AgentTimelineStore` authoritative for committed history
- write one committed row per finalized logical item
- support tail, before-seq, and after-seq queries from storage
- stop treating provider history hydration as the normal refresh/load path
- keep provisional live updates in memory only
Exit gate:
- committed history survives daemon restart
- reconnect uses committed catch-up plus future live events without gaps or duplicates
- unloaded agents can serve committed history from storage alone
### Phase 5: Legacy cleanup
Goal:
Remove compatibility paths after the DB-backed model is fully authoritative.
Required work:
- remove legacy JSON authority for structured records
- remove in-memory committed-history ownership
- remove provider-history rehydrate compatibility paths
- trim dead protocol and reducer logic from the pre-storage model
- update architecture docs to match the final model
Exit gate:
- there is one durable storage path for structured records
- there is one durable storage path for committed timeline history
- the runtime no longer depends on the removed JSON/in-memory model
## Data model summary
### Projects
- integer primary key
- `directory` is unique
- `display_name`
- `kind`: `git | directory`
- optional `git_remote`
- timestamps and archive state
### Workspaces
- integer primary key
- belongs to a project by `project_id`
- `directory` is unique
- `display_name`
- `kind`: `checkout | worktree`
- timestamps and archive state
### Agent snapshots
- `agent_id` remains the primary key
- belongs to a workspace by integer `workspace_id`
- `workspace_id` is required
- timestamps, lifecycle state, persistence metadata, attention metadata, archive state
### Timeline rows
Target shape once Phase 4 lands:
- `agent_id`
- committed `seq`
- committed timestamp
- canonical finalized item payload
Not part of durable history:
- raw streaming chunks
- provisional assistant text
- provisional reasoning text
## Verification requirements
Every remaining phase should keep the same bar:
- `npm run typecheck`
- targeted tests for the touched storage/session/runtime paths
- migration/import coverage when storage authority changes
- reconnect and catch-up scenario coverage when timeline behavior changes
At minimum, timeline cutover must explicitly prove:
- `fetch-after-seq`
- `fetch-before-seq`
- restart durability
- no-gap/no-duplicate reconnect behavior
## Main risks
- timeline work reintroduces provider-history replay as hidden authority
- archive behavior diverges between stored records and live in-memory agents
- explicit creation work leaves the transitional cwd fallback in place too long
- cleanup stalls after compatibility paths stop being exercised
## Rule of thumb
If a new change needs to ask "what can we infer from this cwd?" for project or workspace identity,
it is probably moving in the wrong direction.

1332
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,10 +5,9 @@
"description": "Paseo desktop app (Electron wrapper)",
"main": "dist/main.js",
"scripts": {
"build": "npm --prefix ../.. run build:daemon && npm run build:main && npm run rebuild-native && electron-builder --config electron-builder.yml",
"build": "npm --prefix ../.. run build:daemon && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"dev": "./scripts/dev.sh",
"rebuild-native": "electron-rebuild -m ../.. -o better-sqlite3",
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
@@ -20,7 +19,6 @@
"ws": "^8.14.2"
},
"devDependencies": {
"@electron/rebuild": "^4.0.3",
"@types/node": "24.6.0",
"@types/ws": "^8.5.14",
"electron": "41.0.3",

View File

@@ -1,9 +0,0 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./packages/server/src/server/db/schema.ts",
out: "./packages/server/src/server/db/migrations",
dialect: "sqlite",
strict: true,
verbose: true,
});

View File

@@ -31,7 +31,7 @@
"dev": "cross-env NODE_ENV=development tsx scripts/dev-runner.ts",
"dev:tsx": "cross-env NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true}); fs.cpSync('src/server/db/migrations','dist/server/server/db/migrations',{recursive:true});\"",
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
"prepack": "npm run build",
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
@@ -41,7 +41,6 @@
"speech:download": "tsx scripts/download-speech-models.ts",
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"db:query": "tsx scripts/db-query.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
@@ -71,8 +70,6 @@
"ai": "5.0.78",
"ajv": "^8.17.1",
"dotenv": "^17.2.3",
"better-sqlite3": "^12.8.0",
"drizzle-orm": "^0.45.1",
"express": "^4.18.2",
"express-basic-auth": "^1.2.1",
"fast-deep-equal": "^3.1.3",
@@ -98,8 +95,6 @@
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@types/better-sqlite3": "^7.6.13",
"drizzle-kit": "^0.31.10",
"@types/express": "^4.17.20",
"@types/node": "^20.9.0",
"@types/qrcode": "^1.5.6",

View File

@@ -1,140 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Run arbitrary SQL against the Paseo SQLite database.
*
* Usage:
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
* npx tsx packages/server/scripts/db-query.ts --db ~/.paseo/db "SELECT count(*) FROM agent_timeline_rows"
*
* Without args, shows table row counts.
*/
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import Database from "better-sqlite3";
function resolveHomeDirectory(value: string): string {
if (value === "~") {
return os.homedir();
}
if (value.startsWith("~/")) {
return path.join(os.homedir(), value.slice(2));
}
return value;
}
function parseListenPort(listen: unknown): number | null {
if (typeof listen !== "string") {
return null;
}
const portMatch = listen.match(/:(\d+)$/);
return portMatch ? parseInt(portMatch[1]!, 10) : null;
}
function findDevDatabaseDirectory(): string | null {
const tmpDir = os.tmpdir();
for (const entry of fs.readdirSync(tmpDir)) {
if (entry.startsWith("paseo-dev.")) {
const configPath = path.join(tmpDir, entry, "config.json");
if (fs.existsSync(configPath)) {
try {
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
const dbDir = config.paseoHome ? path.join(config.paseoHome, "db") : null;
const port = parseListenPort(config.daemon?.listen);
if (dbDir && port === 6767) {
return dbDir;
}
} catch {}
}
}
}
return null;
}
function resolveDatabasePath(explicitPath?: string): string {
if (explicitPath) {
const resolvedPath = path.resolve(resolveHomeDirectory(explicitPath));
return fs.statSync(resolvedPath).isDirectory()
? path.join(resolvedPath, "paseo.sqlite")
: resolvedPath;
}
const detectedDevDir = findDevDatabaseDirectory();
if (detectedDevDir) {
return path.join(detectedDevDir, "paseo.sqlite");
}
const paseoHome = process.env.PASEO_HOME
? path.resolve(resolveHomeDirectory(process.env.PASEO_HOME))
: path.join(os.homedir(), ".paseo");
return path.join(paseoHome, "db", "paseo.sqlite");
}
async function main() {
const args = process.argv.slice(2);
let dbPath: string | undefined;
const queries: string[] = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === "--db" && args[i + 1]) {
dbPath = args[++i];
} else {
queries.push(args[i]!);
}
}
if (queries.length === 0) {
queries.push(
"SELECT 'agent_snapshots' AS table_name, count(*) AS rows FROM agent_snapshots UNION ALL " +
"SELECT 'agent_timeline_rows', count(*) FROM agent_timeline_rows UNION ALL " +
"SELECT 'projects', count(*) FROM projects UNION ALL " +
"SELECT 'workspaces', count(*) FROM workspaces " +
"ORDER BY table_name",
);
}
let databasePath = "";
let client: Database.Database | null = null;
try {
if (dbPath) {
const resolvedDbPath = path.resolve(resolveHomeDirectory(dbPath));
databasePath =
fs.existsSync(resolvedDbPath) && fs.statSync(resolvedDbPath).isDirectory()
? path.join(resolvedDbPath, "paseo.sqlite")
: resolvedDbPath;
} else {
databasePath = resolveDatabasePath();
}
client = new Database(databasePath, { readonly: true, fileMustExist: true });
for (const sql of queries) {
const statement = client.prepare(sql);
if (statement.reader) {
const rows = statement.all();
if (rows.length === 0) {
console.log("(0 rows)\n");
} else {
console.table(rows);
}
continue;
}
const result = statement.run();
console.log(`OK (${result.changes} changes)\n`);
}
} catch (err: any) {
console.error(`Error: ${err.message}\nDatabase: ${databasePath}`);
process.exitCode = 1;
} finally {
client?.close();
}
}
main();

View File

@@ -3,7 +3,7 @@ import type pino from "pino";
import type { ManagedAgent } from "./agent/agent-manager.js";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
@@ -18,7 +18,7 @@ export type AgentLoadingServiceOptions = {
AgentManager,
"createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence"
>;
agentStorage: Pick<AgentSnapshotStore, "get">;
agentStorage: Pick<AgentStorage, "get">;
logger: pino.Logger;
};

View File

@@ -35,7 +35,7 @@ import {
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentStorage } from "./agent-storage.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -64,7 +64,7 @@ import {
export interface AgentManagementMcpOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;

View File

@@ -5,10 +5,6 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DbAgentSnapshotStore } from "../db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "../db/db-agent-timeline-store.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "../db/sqlite-database.js";
import { projects, workspaces } from "../db/schema.js";
import { AgentManager } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import type {
@@ -57,37 +53,6 @@ function createFeature(args: { id: string; label: string; value: boolean }): Age
};
}
async function seedWorkspace(
database: PaseoDatabaseHandle,
options: { directory: string },
): Promise<number> {
const [project] = await database.db
.insert(projects)
.values({
directory: options.directory,
kind: "git",
displayName: "project-1",
gitRemote: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: options.directory,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
return workspace.id;
}
class TestAgentClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
@@ -950,209 +915,64 @@ describe("AgentManager", () => {
test("reloadAgentSession preserves current title when config title is unset", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
const dataDir = join(workdir, "db");
const database = await openPaseoDatabase(dataDir);
try {
const workspaceId = await seedWorkspace(database, { directory: workdir });
const storage = new DbAgentSnapshotStore(database.db);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000126",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId },
);
await manager.setTitle(snapshot.id, "Generated title");
const beforeReload = await storage.get(snapshot.id);
expect(beforeReload?.title).toBe("Generated title");
expect(beforeReload?.config?.title).toBeUndefined();
await manager.reloadAgentSession(snapshot.id);
const afterReload = await storage.get(snapshot.id);
expect(afterReload?.title).toBe("Generated title");
expect(afterReload?.config?.title).toBeUndefined();
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
});
test("resumeAgentFromPersistence reads durable helpers without loading committed rows into live memory", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-seed-"));
const storagePath = join(workdir, "agents");
const dataDir = join(workdir, "db");
const storage = new AgentStorage(storagePath, logger);
const database = await openPaseoDatabase(dataDir);
let historyReplayCount = 0;
let manager: AgentManager | null = null;
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000126",
});
class HistoryReplayProbeSession extends TestAgentSession {
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
historyReplayCount += 1;
yield {
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "provider history replay" },
};
}
}
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.setTitle(snapshot.id, "Generated title");
class HistoryReplayProbeClient implements AgentClient {
readonly provider = "codex" as const;
readonly capabilities = TEST_CAPABILITIES;
const beforeReload = await storage.get(snapshot.id);
expect(beforeReload?.title).toBe("Generated title");
expect(beforeReload?.config?.title).toBeUndefined();
async isAvailable(): Promise<boolean> {
return true;
}
await manager.reloadAgentSession(snapshot.id);
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new HistoryReplayProbeSession(config);
}
async resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
return new HistoryReplayProbeSession({
...metadata,
...overrides,
provider: "codex",
cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(),
});
}
}
try {
const durableTimelineStore = new DbAgentTimelineStore(database.db);
manager = new AgentManager({
clients: {
codex: new HistoryReplayProbeClient(),
},
registry: storage,
durableTimelineStore,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000128",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.appendTimelineItem(snapshot.id, {
type: "assistant_message",
text: "durable only",
});
await manager.flush();
const handle = manager.getAgent(snapshot.id)?.persistence;
expect(handle).not.toBeNull();
if (!handle) {
throw new Error("Expected persistence handle to be available");
}
await manager.closeAgent(snapshot.id);
await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([
{
seq: 1,
timestamp: expect.any(String),
item: {
type: "assistant_message",
text: "durable only",
},
},
]);
const resumed = await manager.resumeAgentFromPersistence(handle, undefined, snapshot.id);
expect(resumed.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only");
await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([
{
seq: 1,
timestamp: expect.any(String),
item: {
type: "assistant_message",
text: "durable only",
},
},
]);
await manager.hydrateTimelineFromProvider(snapshot.id);
expect(historyReplayCount).toBe(0);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
await manager.closeAgent(snapshot.id);
await manager.deleteCommittedTimeline(snapshot.id);
await expect(durableTimelineStore.getCommittedRows(snapshot.id)).resolves.toEqual([]);
} finally {
await manager?.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
const afterReload = await storage.get(snapshot.id);
expect(afterReload?.title).toBe("Generated title");
expect(afterReload?.config?.title).toBeUndefined();
});
test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-"));
const dataDir = join(workdir, "db");
const database = await openPaseoDatabase(dataDir);
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000127",
});
try {
const workspaceId = await seedWorkspace(database, { directory: workdir });
const storage = new DbAgentSnapshotStore(database.db);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000127",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
},
undefined,
{ workspaceId },
);
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
await manager.setTitle(snapshot.id, "Generated title");
await manager.setTitle(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Generated title");
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Generated title");
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
const live = manager.getAgent(snapshot.id);
expect(live).not.toBeNull();
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
const live = manager.getAgent(snapshot.id);
expect(live).not.toBeNull();
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
});
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
@@ -1509,67 +1329,6 @@ describe("AgentManager", () => {
});
});
test("fetchTimeline and getTimelineRows prefer the durable store while live helpers stay in-memory", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-durable-read-authority-"));
const storagePath = join(workdir, "agents");
const dataDir = join(workdir, "db");
const storage = new AgentStorage(storagePath, logger);
const database = await openPaseoDatabase(dataDir);
try {
const durableTimelineStore = new DbAgentTimelineStore(database.db);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
durableTimelineStore,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000139",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const durableOnlyItem: AgentTimelineItem = {
type: "assistant_message",
text: "durable only",
};
const durableOnlyRow = {
seq: 1,
timestamp: "2026-03-24T00:00:01.000Z",
item: durableOnlyItem,
};
await durableTimelineStore.bulkInsert(snapshot.id, [durableOnlyRow]);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
await expect(manager.getLastAssistantMessage(snapshot.id)).resolves.toBe("durable only");
await expect(manager.getTimelineRows(snapshot.id)).resolves.toEqual([durableOnlyRow]);
await expect(
manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
}),
).resolves.toEqual({
direction: "tail",
window: {
minSeq: 1,
maxSeq: 1,
nextSeq: 2,
},
hasOlder: false,
hasNewer: false,
rows: [durableOnlyRow],
});
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
});
test("getTimelineRows falls back to the in-memory timeline when no durable store is configured", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-"));
const storagePath = join(workdir, "agents");
@@ -2079,41 +1838,29 @@ describe("AgentManager", () => {
test("createAgent persists provided title before returning", async () => {
const agentId = "00000000-0000-4000-8000-000000000102";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const dataDir = join(workdir, "db");
const database = await openPaseoDatabase(dataDir);
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => agentId,
});
try {
const workspaceId = await seedWorkspace(database, { directory: workdir });
const storage = new DbAgentSnapshotStore(database.db);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => agentId,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Fix Login Bug",
});
const snapshot = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Fix Login Bug",
},
undefined,
{ workspaceId },
);
expect(snapshot.id).toBe(agentId);
expect(snapshot.lifecycle).toBe("idle");
expect(snapshot.id).toBe(agentId);
expect(snapshot.lifecycle).toBe("idle");
const persisted = await storage.get(agentId);
expect(persisted?.title).toBe("Fix Login Bug");
expect(persisted?.id).toBe(agentId);
} finally {
await database.close();
rmSync(workdir, { recursive: true, force: true });
}
const persisted = await storage.get(agentId);
expect(persisted?.title).toBe("Fix Login Bug");
expect(persisted?.id).toBe(agentId);
});
test("createAgent populates runtimeInfo after session creation", async () => {

View File

@@ -33,8 +33,7 @@ import type {
ListPersistedAgentsOptions,
PersistedAgentDescriptor,
} from "./agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import {
InMemoryAgentTimelineStore,
type SeedAgentTimelineOptions,
@@ -92,7 +91,7 @@ export type ProviderAvailability = {
export type AgentManagerOptions = {
clients?: Partial<Record<AgentProvider, AgentClient>>;
idFactory?: () => string;
registry?: AgentSnapshotStore;
registry?: AgentStorage;
onAgentAttention?: AgentAttentionCallback;
durableTimelineStore?: AgentTimelineStore;
terminalManager?: TerminalManager | null;
@@ -306,7 +305,7 @@ export class AgentManager {
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
private readonly subscribers = new Set<SubscriptionRecord>();
private readonly idFactory: () => string;
private readonly registry?: AgentSnapshotStore;
private readonly registry?: AgentStorage;
private readonly durableTimelineStore?: AgentTimelineStore;
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
private readonly backgroundTasks = new Set<Promise<void>>();
@@ -605,7 +604,7 @@ export class AgentManager {
agentId?: string,
options?: {
labels?: Record<string, string>;
workspaceId?: number;
workspaceId?: string;
initialPrompt?: string;
},
): Promise<ManagedAgent> {
@@ -1812,7 +1811,7 @@ export class AgentManager {
config: AgentSessionConfig,
agentId: string,
options?: {
workspaceId?: number;
workspaceId?: string;
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
@@ -2093,7 +2092,7 @@ export class AgentManager {
private async persistSnapshot(
agent: ManagedAgent,
options?: { workspaceId?: number; title?: string | null; internal?: boolean },
options?: { workspaceId?: string; title?: string | null; internal?: boolean },
): Promise<void> {
if (!this.registry) {
return;
@@ -2109,7 +2108,7 @@ export class AgentManager {
await this.registry.applySnapshot(agent, options);
}
private requireRegistry(): AgentSnapshotStore {
private requireRegistry(): AgentStorage {
if (!this.registry) {
throw new Error("Agent storage unavailable");
}

View File

@@ -1,19 +0,0 @@
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>;
applySnapshot(
agent: ManagedAgent,
workspaceId: number,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
setTitle(agentId: string, title: string): Promise<void>;
}

View File

@@ -7,7 +7,6 @@ import type { Logger } from "pino";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
@@ -84,7 +83,7 @@ export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value);
}
export class AgentStorage implements AgentSnapshotStore {
export class AgentStorage {
private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
@@ -182,10 +181,10 @@ export class AgentStorage implements AgentSnapshotStore {
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
await this.load();
await this.waitForPendingWrite(agent.id);
const existing = (await this.get(agent.id)) ?? null;

View File

@@ -6,12 +6,12 @@ import { tmpdir } from "node:os";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { createAgentMcpServer } from "./mcp-server.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentStorage } from "./agent-storage.js";
import type { ProviderDefinition } from "./provider-registry.js";
type TestDeps = {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
spies: {
agentManager: Record<string, any>;
agentStorage: Record<string, any>;
@@ -46,7 +46,7 @@ function createTestDeps(): TestDeps {
return {
agentManager: agentManagerSpies as unknown as AgentManager,
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
agentStorage: agentStorageSpies as unknown as AgentStorage,
spies: {
agentManager: agentManagerSpies,
agentStorage: agentStorageSpies,

View File

@@ -14,7 +14,7 @@ import {
} from "../messages.js";
import { toAgentPayload } from "./agent-projections.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentStorage } from "./agent-storage.js";
import {
appendTimelineItemIfAgentKnown,
emitLiveTimelineItemIfAgentKnown,
@@ -47,7 +47,7 @@ import {
export interface AgentMcpServerOptions {
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
terminalManager?: TerminalManager | null;
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;

View File

@@ -4,7 +4,7 @@ import type { Logger } from "pino";
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
import { curateAgentActivity } from "./activity-curator.js";
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
import type { AgentStorage } from "./agent-storage.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
@@ -253,7 +253,7 @@ export function sanitizePermissionRequest(
}
export async function resolveAgentTitle(
agentStorage: AgentSnapshotStore,
agentStorage: AgentStorage,
agentId: string,
logger: Logger,
): Promise<string | null> {
@@ -267,7 +267,7 @@ export async function resolveAgentTitle(
}
export async function serializeSnapshotWithMetadata(
agentStorage: AgentSnapshotStore,
agentStorage: AgentStorage,
snapshot: ManagedAgent,
logger: Logger,
) {

View File

@@ -1,7 +1,5 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { Writable } from "node:stream";
import pino from "pino";
@@ -10,8 +8,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
import { openPaseoDatabase } from "./db/sqlite-database.js";
import { agentSnapshots, projects, workspaces } from "./db/schema.js";
describe("paseo daemon bootstrap", () => {
afterEach(() => {
@@ -203,432 +199,4 @@ describe("paseo daemon bootstrap", () => {
await rm(staticDir, { recursive: true, force: true });
}
});
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
try {
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
expect(projectRows).toHaveLength(1);
expect(projectRows[0]).toMatchObject({
directory: projectDir,
kind: "git",
createdAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toHaveLength(1);
expect(workspaceRows[0]).toMatchObject({
projectId: projectRows[0]!.id,
directory: projectDir,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
});
} finally {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
try {
const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await firstDaemon.start();
await firstDaemon.stop();
const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await secondDaemon.start();
await secondDaemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", {
id: "agent-1",
provider: "codex",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: "Imported Agent",
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1-codex-mini", modeId: "plan" },
runtimeInfo: {
provider: "codex",
sessionId: "session-123",
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
});
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
const workspaceRows = await database.db.select().from(workspaces);
const agentRows = await database.db.select().from(agentSnapshots);
expect(projectRows).toHaveLength(1);
expect(workspaceRows).toHaveLength(1);
expect(agentRows).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: projectDir,
workspaceId: workspaceRows[0]!.id,
title: "Imported Agent",
requiresAttention: false,
internal: false,
}),
]);
} finally {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("imports large legacy agent JSON batches during SQLite bootstrap", async () => {
const { config, cleanup } = await createBootstrapConfig();
const projectDir = await mkdtemp(path.join(os.tmpdir(), "paseo-bootstrap-project-"));
initializeGitRepo(projectDir);
writeLegacyProjectWorkspaceJson(config.paseoHome, {
projects: [
{
projectId: "project-1",
rootPath: projectDir,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspaces: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: projectDir,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
for (let index = 0; index < 150; index += 1) {
writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, {
id: `agent-${index}`,
provider: "codex",
cwd: projectDir,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: `Imported Agent ${index}`,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1-codex-mini", modeId: "plan" },
runtimeInfo: {
provider: "codex",
sessionId: `session-${index}`,
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
persistence: null,
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
});
}
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
const projectRows = await database.db.select().from(projects);
const workspaceRows = await database.db.select().from(workspaces);
const agentRows = await database.db.select().from(agentSnapshots);
expect(projectRows).toHaveLength(1);
expect(workspaceRows).toHaveLength(1);
expect(agentRows).toHaveLength(150);
expect(agentRows[0]?.workspaceId).toBe(workspaceRows[0]!.id);
expect(agentRows.map((row) => row.agentId)).toContain("agent-149");
} finally {
await database.close();
}
} finally {
await rm(projectDir, { recursive: true, force: true });
await cleanup();
}
});
test("reconciles workspace records into the DB without recreating legacy JSON registry files", async () => {
const { config, cleanup } = await createBootstrapConfig();
const agentStorageDir = path.join(config.paseoHome, "agents");
mkdirSync(agentStorageDir, { recursive: true });
const storageBucket = path.join(agentStorageDir, "tmp-db-only-project");
mkdirSync(storageBucket, { recursive: true });
writeFileSync(
path.join(storageBucket, "agent-1.json"),
JSON.stringify(
{
id: "agent-1",
provider: "codex",
cwd: "/tmp/db-only-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
lastActivityAt: "2026-03-02T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: null,
config: null,
runtimeInfo: { provider: "codex", sessionId: null },
persistence: null,
archivedAt: null,
},
null,
2,
),
"utf8",
);
try {
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
await daemon.start();
await daemon.stop();
expect(existsSync(path.join(config.paseoHome, "db", "paseo.sqlite"))).toBe(true);
const database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
try {
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/db-only-project",
requiresAttention: false,
internal: false,
}),
]);
expect(await database.db.select().from(projects)).toHaveLength(1);
expect(await database.db.select().from(workspaces)).toHaveLength(1);
} finally {
await database.close();
}
expect(existsSync(path.join(config.paseoHome, "projects", "projects.json"))).toBe(false);
expect(existsSync(path.join(config.paseoHome, "projects", "workspaces.json"))).toBe(false);
} finally {
await cleanup();
}
});
});
async function createBootstrapConfig(): Promise<{
config: PaseoDaemonConfig;
cleanup: () => Promise<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",
);
}
function writeLegacyAgentJson(
paseoHome: string,
relativePath: string,
payload: Record<string, unknown>,
): void {
const absolutePath = path.join(paseoHome, relativePath);
mkdirSync(path.dirname(absolutePath), { recursive: true });
writeFileSync(absolutePath, JSON.stringify(payload, null, 2), "utf8");
}
function initializeGitRepo(directory: string): void {
execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.dev"], {
cwd: directory,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: directory, stdio: "pipe" });
writeFileSync(path.join(directory, "README.md"), "bootstrap fixture\n", "utf8");
execFileSync("git", ["add", "README.md"], { cwd: directory, stdio: "pipe" });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "init"], {
cwd: directory,
stdio: "pipe",
});
}

View File

@@ -93,21 +93,16 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
import { createSpeechService } from "./speech/speech-runtime.js";
import { AgentManager } from "./agent/agent-manager.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import {
buildProviderRegistry,
createAllClients,
shutdownProviders,
} from "./agent/provider-registry.js";
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { DbProjectRegistry } from "./db/db-project-registry.js";
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
@@ -201,7 +196,7 @@ export type PaseoDaemonConfig = {
export interface PaseoDaemon {
config: PaseoDaemonConfig;
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
terminalManager: TerminalManager;
scriptRouteStore: ScriptRouteStore;
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
@@ -218,7 +213,6 @@ export async function createPaseoDaemon(
const bootstrapStart = performance.now();
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
const daemonVersion = resolveDaemonVersion(import.meta.url);
let database: PaseoDatabaseHandle | null = null;
const daemonConfigStore = new DaemonConfigStore(
config.paseoHome,
{
@@ -384,9 +378,6 @@ export async function createPaseoDaemon(
const httpServer = createHTTPServer(app);
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
logger.info({ elapsed: elapsed() }, "Paseo database opened");
// Script proxy WebSocket upgrade handler — must be registered before the
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
// script-bound upgrades are forwarded first. The handler is a no-op for
@@ -397,15 +388,20 @@ export async function createPaseoDaemon(
});
httpServer.on("upgrade", scriptProxyUpgradeHandler);
const agentStorage = new DbAgentSnapshotStore(database.db);
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
const projectRegistry = new FileBackedProjectRegistry(
path.join(config.paseoHome, "projects", "projects.json"),
logger,
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
const chatService = new FileBackedChatService({
paseoHome: config.paseoHome,
logger,
});
const durableTimelineStore = new DbAgentTimelineStore(database.db);
let agentManager: AgentManager | null = null;
const terminalManager = createTerminalManager();
agentManager = new AgentManager({
const agentManager = new AgentManager({
clients: {
...createAllClients(logger, {
runtimeSettings: config.agentProviderSettings,
@@ -414,8 +410,6 @@ export async function createPaseoDaemon(
...config.agentClients,
},
registry: agentStorage,
durableTimelineStore,
terminalManager,
logger,
});
const providerRegistry = buildProviderRegistry(logger, {
@@ -423,37 +417,23 @@ export async function createPaseoDaemon(
providerOverrides: config.providerOverrides,
});
const projectRegistry = new DbProjectRegistry(database.db);
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
const terminalManager = createTerminalManager();
try {
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
} catch (err) {
logger.error({ err }, "Legacy project/workspace import failed (non-fatal)");
}
try {
await importLegacyAgentSnapshots({
db: database.db,
paseoHome: config.paseoHome,
logger,
});
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
} catch (err) {
logger.error({ err }, "Legacy agent snapshot import failed (non-fatal)");
}
const reconciliationService = new WorkspaceReconciliationService({
const detachAgentStoragePersistence = attachAgentStoragePersistence(
logger,
agentManager,
agentStorage,
);
await agentStorage.initialize();
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
await bootstrapWorkspaceRegistries({
paseoHome: config.paseoHome,
agentStorage,
projectRegistry,
workspaceRegistry,
logger,
});
reconciliationService.start();
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const checkoutDiffManager = new CheckoutDiffManager({
@@ -755,10 +735,11 @@ export async function createPaseoDaemon(
};
const stop = async () => {
reconciliationService.stop();
scriptHealthMonitor.stop();
await closeAllAgents(logger, agentManager);
await agentManager.flush().catch(() => undefined);
detachAgentStoragePersistence();
await agentStorage.flush().catch(() => undefined);
await shutdownProviders(logger, {
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
@@ -770,7 +751,6 @@ export async function createPaseoDaemon(
if (wsServer) {
await wsServer.close();
}
await database?.close().catch(() => undefined);
await new Promise<void>((resolve) => {
httpServer.close(() => resolve());
});
@@ -792,7 +772,6 @@ export async function createPaseoDaemon(
getListenTarget: () => boundListenTarget,
};
} catch (err) {
await database?.close().catch(() => undefined);
throw err;
}
}

View File

@@ -1,285 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type {
AgentPermissionRequest,
AgentSession,
AgentSessionConfig,
} from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbAgentSnapshotStore } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
type ManagedAgentOverrides = Omit<
Partial<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 () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
const record = createStoredAgentRecord();
expect(await store.list()).toEqual([]);
expect(await store.get(record.id)).toBeNull();
await store.upsert(record, workspaceId);
expect(await store.get(record.id)).toEqual(record);
expect(await store.list()).toEqual([record]);
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
workspaceId,
requiresAttention: false,
internal: false,
}),
]);
await store.remove(record.id);
expect(await store.get(record.id)).toBeNull();
expect(await store.list()).toEqual([]);
});
test("applySnapshot preserves title, createdAt, and archivedAt across updates", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await store.upsert(
createStoredAgentRecord({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
}),
workspaceId,
);
await store.applySnapshot(
createManagedAgent({
id: "agent-apply",
createdAt: new Date("2026-03-10T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
lifecycle: "running",
}),
workspaceId,
);
expect(await store.get("agent-apply")).toEqual(
expect.objectContaining({
id: "agent-apply",
title: "Pinned title",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-11T00:00:00.000Z",
archivedAt: "2026-03-05T00:00:00.000Z",
lastStatus: "running",
}),
);
});
test("setTitle throws for missing agents and updates existing agents", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await expect(store.setTitle("missing-agent", "Missing")).rejects.toThrow(
"Agent missing-agent not found",
);
await store.upsert(createStoredAgentRecord({ id: "agent-title", title: null }), workspaceId);
await store.setTitle("agent-title", "Renamed agent");
expect(await store.get("agent-title")).toEqual(
expect.objectContaining({
id: "agent-title",
title: "Renamed agent",
}),
);
});
test("upsert is idempotent for the same agent ID", async () => {
const workspaceId = await seedWorkspace(database, { directory: "/tmp/project" });
await store.upsert(
createStoredAgentRecord({ id: "agent-idempotent", title: "Initial" }),
workspaceId,
);
await store.upsert(
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
workspaceId,
);
expect(await store.list()).toEqual([
createStoredAgentRecord({
id: "agent-idempotent",
title: "Updated",
updatedAt: "2026-03-02T00:00:00.000Z",
lastStatus: "running",
}),
]);
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
});
async function seedWorkspace(
database: PaseoDatabaseHandle,
options: { directory: string },
): Promise<number> {
const [project] = await database.db
.insert(projects)
.values({
directory: options.directory,
kind: "git",
displayName: "project-1",
gitRemote: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: options.directory,
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning();
return workspace.id;
}

View File

@@ -1,208 +0,0 @@
import { asc, eq } from "drizzle-orm";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import { toStoredAgentRecord } from "../agent/agent-projections.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { agentSnapshots } from "./schema.js";
type AgentSnapshotRow = typeof agentSnapshots.$inferSelect;
type AgentSnapshotInsert = typeof agentSnapshots.$inferInsert;
export function toStoredAgentRecordFromRow(row: AgentSnapshotRow): StoredAgentRecord {
return {
id: row.agentId,
provider: row.provider,
cwd: row.cwd,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastActivityAt: row.lastActivityAt ?? undefined,
lastUserMessageAt: row.lastUserMessageAt ?? null,
title: row.title ?? null,
labels: row.labels,
lastStatus: row.lastStatus as StoredAgentRecord["lastStatus"],
lastModeId: row.lastModeId ?? null,
config: row.config ?? null,
runtimeInfo: row.runtimeInfo ?? undefined,
persistence: row.persistence ?? null,
requiresAttention: row.requiresAttention,
attentionReason: (row.attentionReason ?? null) as StoredAgentRecord["attentionReason"],
attentionTimestamp: row.attentionTimestamp ?? null,
internal: row.internal,
archivedAt: row.archivedAt ?? null,
};
}
export function toAgentSnapshotRowValues(options: {
record: StoredAgentRecord;
workspaceId: number;
}): AgentSnapshotInsert {
const { record, workspaceId } = options;
return {
agentId: record.id,
provider: record.provider,
workspaceId,
cwd: record.cwd,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
lastActivityAt: record.lastActivityAt ?? null,
lastUserMessageAt: record.lastUserMessageAt ?? null,
title: record.title ?? null,
labels: record.labels,
lastStatus: record.lastStatus,
lastModeId: record.lastModeId ?? null,
config: record.config ?? null,
runtimeInfo: record.runtimeInfo ?? null,
persistence: record.persistence ?? null,
requiresAttention: record.requiresAttention ?? false,
attentionReason: record.attentionReason ?? null,
attentionTimestamp: record.attentionTimestamp ?? null,
internal: record.internal ?? false,
archivedAt: record.archivedAt ?? null,
};
}
function toAgentSnapshotUpdateSet(values: AgentSnapshotInsert) {
return {
provider: values.provider,
workspaceId: values.workspaceId,
cwd: values.cwd,
createdAt: values.createdAt,
updatedAt: values.updatedAt,
lastActivityAt: values.lastActivityAt,
lastUserMessageAt: values.lastUserMessageAt,
title: values.title,
labels: values.labels,
lastStatus: values.lastStatus,
lastModeId: values.lastModeId,
config: values.config,
runtimeInfo: values.runtimeInfo,
persistence: values.persistence,
requiresAttention: values.requiresAttention,
attentionReason: values.attentionReason,
attentionTimestamp: values.attentionTimestamp,
internal: values.internal,
archivedAt: values.archivedAt,
} satisfies Omit<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>;
async upsert(record: StoredAgentRecord, workspaceId: number): Promise<void>;
async upsert(record: StoredAgentRecord, workspaceId?: number): Promise<void> {
const nextWorkspaceId =
workspaceId ??
(
await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, record.id))
.limit(1)
)[0]?.workspaceId;
if (nextWorkspaceId === undefined) {
throw new Error(`Workspace ID required for agent ${record.id}`);
}
const values = toAgentSnapshotRowValues({
record,
workspaceId: nextWorkspaceId,
});
await this.db
.insert(agentSnapshots)
.values(values)
.onConflictDoUpdate({
target: agentSnapshots.agentId,
set: toAgentSnapshotUpdateSet(values),
});
}
async remove(agentId: string): Promise<void> {
await this.db.delete(agentSnapshots).where(eq(agentSnapshots.agentId, agentId));
}
async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
async applySnapshot(
agent: ManagedAgent,
workspaceId: number,
options?: { title?: string | null; internal?: boolean },
): Promise<void>;
async applySnapshot(
agent: ManagedAgent,
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
const nextWorkspaceId =
typeof workspaceIdOrOptions === "number"
? workspaceIdOrOptions
: (
await this.db
.select({ workspaceId: agentSnapshots.workspaceId })
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agent.id))
.limit(1)
)[0]?.workspaceId;
const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
const existing = await this.get(agent.id);
const hasTitleOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
const hasInternalOverride =
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
const record = toStoredAgentRecord(agent, {
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
createdAt: existing?.createdAt,
internal: hasInternalOverride
? nextOptions?.internal
: (agent.internal ?? existing?.internal),
});
if (existing && existing.archivedAt !== undefined) {
record.archivedAt = existing.archivedAt;
}
if (nextWorkspaceId === undefined) {
return;
}
await this.upsert(record, nextWorkspaceId);
}
async setTitle(agentId: string, title: string): Promise<void> {
const rows = await this.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, agentId))
.limit(1);
const row = rows[0];
if (!row) {
throw new Error(`Agent ${agentId} not found`);
}
await this.upsert({ ...toStoredAgentRecordFromRow(row), title }, row.workspaceId);
}
}

View File

@@ -1,275 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbAgentTimelineStore } from "./db-agent-timeline-store.js";
import { agentTimelineRows } from "./schema.js";
function createTimestamp(seq: number): string {
return new Date(Date.UTC(2026, 2, 1, 0, 0, seq)).toISOString();
}
function createTimelineItem(
type: Extract<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);
});
});

View File

@@ -1,311 +0,0 @@
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import type {
AgentTimelineFetchOptions,
AgentTimelineFetchResult,
AgentTimelineRow,
AgentTimelineStore,
AgentTimelineWindow,
} from "../agent/agent-timeline-store-types.js";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { agentTimelineRows } from "./schema.js";
type AgentTimelineRowRecord = typeof agentTimelineRows.$inferSelect;
type AgentTimelineRowInsert = typeof agentTimelineRows.$inferInsert;
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
if (typeof messageId !== "string") {
return undefined;
}
const normalized = messageId.trim();
return normalized.length > 0 ? normalized : undefined;
}
function toTimelineRow(row: AgentTimelineRowRecord): AgentTimelineRow {
return {
seq: row.seq,
timestamp: row.committedAt,
item: row.item,
};
}
function toInsertValues(agentId: string, row: AgentTimelineRow): AgentTimelineRowInsert {
return {
agentId,
seq: row.seq,
committedAt: row.timestamp,
item: row.item,
itemKind: row.item.type,
};
}
function normalizeFetchLimit(limit: number | undefined): number {
if (limit === undefined) {
return DEFAULT_TIMELINE_FETCH_LIMIT;
}
return Math.max(0, Math.floor(limit));
}
export class DbAgentTimelineStore implements AgentTimelineStore {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async appendCommitted(
agentId: string,
item: AgentTimelineItem,
options?: { timestamp?: string },
): Promise<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`json_extract(${agentTimelineRows.item}, '$.messageId') = ${messageId}`,
sql`json_extract(${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,
};
}
}

View File

@@ -1,89 +0,0 @@
import { eq } from "drizzle-orm";
import type { ProjectRegistry, PersistedProjectRecord } from "../workspace-registry.js";
import { createPersistedProjectRecord } from "../workspace-registry.js";
import { projects } from "./schema.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
function toPersistedProjectRecord(row: typeof projects.$inferSelect): PersistedProjectRecord {
return createPersistedProjectRecord({
...row,
kind: row.kind as PersistedProjectRecord["kind"],
});
}
export class DbProjectRegistry implements ProjectRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<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(id: number): Promise<PersistedProjectRecord | null> {
const rows = await this.db.select().from(projects).where(eq(projects.id, id)).limit(1);
const row = rows[0];
return row ? toPersistedProjectRecord(row) : null;
}
async insert(record: Omit<PersistedProjectRecord, "id">): Promise<number> {
const [row] = await this.db
.insert(projects)
.values(record)
.onConflictDoUpdate({
target: projects.directory,
set: {
kind: record.kind,
displayName: record.displayName,
gitRemote: record.gitRemote,
updatedAt: record.updatedAt,
archivedAt: record.archivedAt,
},
})
.returning({ id: projects.id });
return row!.id;
}
async upsert(record: PersistedProjectRecord): Promise<void> {
const nextRecord = createPersistedProjectRecord(record);
await this.db
.insert(projects)
.values(nextRecord)
.onConflictDoUpdate({
target: projects.directory,
set: {
kind: nextRecord.kind,
displayName: nextRecord.displayName,
gitRemote: nextRecord.gitRemote,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(id: number, archivedAt: string): Promise<void> {
await this.db
.update(projects)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(projects.id, id));
}
async remove(id: number): Promise<void> {
await this.db.delete(projects).where(eq(projects.id, id));
}
}

View File

@@ -1,204 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "../workspace-registry.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { DbProjectRegistry } from "./db-project-registry.js";
import { DbWorkspaceRegistry } from "./db-workspace-registry.js";
function createProjectRecord(input: Partial<PersistedProjectRecord> = {}): PersistedProjectRecord {
return createPersistedProjectRecord({
id: 1,
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
function createWorkspaceRecord(
input: Partial<PersistedWorkspaceRecord> = {},
): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
id: 1,
projectId: 1,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
...input,
});
}
describe("DB-backed workspace registries", () => {
let tmpDir: string;
let dataDir: string;
let database: PaseoDatabaseHandle;
let projectRegistry: DbProjectRegistry;
let workspaceRegistry: DbWorkspaceRegistry;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "db-workspace-registry-"));
dataDir = path.join(tmpDir, "db");
database = await openPaseoDatabase(dataDir);
projectRegistry = new DbProjectRegistry(database.db);
workspaceRegistry = new DbWorkspaceRegistry(database.db);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("project registry matches the file-backed behavioral contract", async () => {
await projectRegistry.initialize();
expect(await projectRegistry.existsOnDisk()).toBe(true);
expect(await projectRegistry.get(999)).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await projectRegistry.upsert(
createProjectRecord({
id: projectId,
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z");
await projectRegistry.archive(999, "2026-03-04T00:00:00.000Z");
expect(await projectRegistry.get(projectId)).toEqual(
createProjectRecord({
id: projectId,
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await projectRegistry.list()).toEqual([
createProjectRecord({
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await projectRegistry.remove(999);
await projectRegistry.remove(projectId);
expect(await projectRegistry.get(projectId)).toBeNull();
expect(await projectRegistry.list()).toEqual([]);
});
test("workspace registry matches the file-backed behavioral contract", async () => {
await workspaceRegistry.initialize();
expect(await workspaceRegistry.existsOnDisk()).toBe(true);
expect(await workspaceRegistry.get(999)).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceId = await workspaceRegistry.insert({
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await workspaceRegistry.upsert(
createWorkspaceRecord({
id: workspaceId,
projectId,
displayName: "feature/workspace",
updatedAt: "2026-03-02T00:00:00.000Z",
}),
);
await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z");
await workspaceRegistry.archive(999, "2026-03-04T00:00:00.000Z");
expect(await workspaceRegistry.get(workspaceId)).toEqual(
createWorkspaceRecord({
id: workspaceId,
projectId,
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
);
expect(await workspaceRegistry.list()).toEqual([
createWorkspaceRecord({
displayName: "feature/workspace",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: "2026-03-03T00:00:00.000Z",
}),
]);
await workspaceRegistry.remove(999);
await workspaceRegistry.remove(workspaceId);
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
expect(await workspaceRegistry.list()).toEqual([]);
});
test("rejects workspace upserts for non-existent projects", async () => {
await expect(
workspaceRegistry.upsert(
createWorkspaceRecord({
projectId: 999,
}),
),
).rejects.toThrow();
});
test("cascades workspace removal when removing a linked project", async () => {
const projectId = await projectRegistry.insert({
directory: "/tmp/repo",
kind: "git",
displayName: "acme/repo",
gitRemote: "git@github.com:acme/repo.git",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
const workspaceId = await workspaceRegistry.insert({
projectId,
directory: "/tmp/repo",
kind: "checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
});
await projectRegistry.remove(projectId);
expect(await projectRegistry.get(projectId)).toBeNull();
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
});
});

View File

@@ -1,89 +0,0 @@
import { eq } from "drizzle-orm";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { workspaces } from "./schema.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "../workspace-registry.js";
import { createPersistedWorkspaceRecord } from "../workspace-registry.js";
function toPersistedWorkspaceRecord(row: typeof workspaces.$inferSelect): PersistedWorkspaceRecord {
return createPersistedWorkspaceRecord({
...row,
kind: row.kind as PersistedWorkspaceRecord["kind"],
});
}
export class DbWorkspaceRegistry implements WorkspaceRegistry {
private readonly db: PaseoDatabaseHandle["db"];
constructor(db: PaseoDatabaseHandle["db"]) {
this.db = db;
}
async initialize(): Promise<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(id: number): Promise<PersistedWorkspaceRecord | null> {
const rows = await this.db.select().from(workspaces).where(eq(workspaces.id, id)).limit(1);
const row = rows[0];
return row ? toPersistedWorkspaceRecord(row) : null;
}
async insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number> {
const [row] = await this.db
.insert(workspaces)
.values(record)
.onConflictDoUpdate({
target: workspaces.directory,
set: {
projectId: record.projectId,
kind: record.kind,
displayName: record.displayName,
updatedAt: record.updatedAt,
archivedAt: record.archivedAt,
},
})
.returning({ id: workspaces.id });
return row!.id;
}
async upsert(record: PersistedWorkspaceRecord): Promise<void> {
const nextRecord = createPersistedWorkspaceRecord(record);
await this.db
.insert(workspaces)
.values(nextRecord)
.onConflictDoUpdate({
target: workspaces.directory,
set: {
projectId: nextRecord.projectId,
kind: nextRecord.kind,
displayName: nextRecord.displayName,
updatedAt: nextRecord.updatedAt,
archivedAt: nextRecord.archivedAt,
},
});
}
async archive(workspaceId: number, archivedAt: string): Promise<void> {
await this.db
.update(workspaces)
.set({
updatedAt: archivedAt,
archivedAt,
})
.where(eq(workspaces.id, workspaceId));
}
async remove(workspaceId: number): Promise<void> {
await this.db.delete(workspaces).where(eq(workspaces.id, workspaceId));
}
}

View File

@@ -1,312 +0,0 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
describe("importLegacyAgentSnapshots", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-agent-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database.close();
rmSync(tmpDir, { recursive: true, force: true });
});
async function seedWorkspace(directory: string): Promise<number> {
const [project] = await database.db
.insert(projects)
.values({
directory,
displayName: path.basename(directory),
kind: "directory",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: projects.id });
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project!.id,
directory,
displayName: path.basename(directory),
kind: "checkout",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: workspaces.id });
return workspace!.id;
}
test("imports agent JSON files when the DB is empty", async () => {
await seedWorkspace("/tmp/project");
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-1.json",
payload: createLegacyAgentJson({
requiresAttention: undefined,
internal: undefined,
}),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 1,
});
expect(await database.db.select().from(agentSnapshots)).toEqual([
expect.objectContaining({
agentId: "agent-1",
cwd: "/tmp/project",
requiresAttention: false,
internal: false,
}),
]);
});
test("skips import when the DB already has agent data", async () => {
const workspaceId = await seedWorkspace("/tmp/existing-project");
await database.db.insert(agentSnapshots).values({
agentId: "existing-agent",
provider: "codex",
workspaceId,
cwd: "/tmp/existing-project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
lastActivityAt: "2026-03-01T00:00:00.000Z",
lastUserMessageAt: null,
title: null,
labels: {},
lastStatus: "idle",
lastModeId: "plan",
config: null,
runtimeInfo: { provider: "codex", sessionId: "session-existing" },
persistence: null,
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/legacy-agent.json",
payload: createLegacyAgentJson({ id: "legacy-agent" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
expect(await database.db.select().from(agentSnapshots)).toHaveLength(1);
});
test("imports agent JSON files from nested project directories", async () => {
await seedWorkspace("/tmp/root-project");
await seedWorkspace("/tmp/nested-project");
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/agent-root.json",
payload: createLegacyAgentJson({ id: "agent-root", cwd: "/tmp/root-project" }),
});
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/tmp-nested-project/agent-nested.json",
payload: createLegacyAgentJson({ id: "agent-nested", cwd: "/tmp/nested-project" }),
});
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 2,
});
expect(
(await database.db.select().from(agentSnapshots)).map((row) => row.agentId).sort(),
).toEqual(["agent-nested", "agent-root"]);
});
test("batches large legacy agent imports so SQLite variable limits do not abort bootstrap", async () => {
await seedWorkspace("/tmp/large-project");
for (let index = 0; index < 150; index += 1) {
writeLegacyAgentJson({
paseoHome,
relativePath: `agents/large-project/agent-${index}.json`,
payload: createLegacyAgentJson({
id: `agent-${index}`,
cwd: "/tmp/large-project",
runtimeInfo: {
provider: "codex",
sessionId: `session-${index}`,
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
}),
});
}
const result = await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedAgents: 150,
});
const rows = await database.db.select().from(agentSnapshots);
expect(rows).toHaveLength(150);
expect(rows.map((row) => row.agentId)).toContain("agent-149");
});
test("creates backup of agent directory before import", async () => {
await seedWorkspace("/tmp/project");
writeLegacyAgentJson({
paseoHome,
relativePath: "agents/project-a/agent-1.json",
payload: createLegacyAgentJson(),
});
await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
const backupPath = path.join(
paseoHome,
"backup",
"pre-migration",
"agents",
"project-a",
"agent-1.json",
);
expect(existsSync(backupPath)).toBe(true);
expect(JSON.parse(readFileSync(backupPath, "utf8"))).toMatchObject({
id: "agent-1",
cwd: "/tmp/project",
});
});
test("logs batch progress for large imports", async () => {
await seedWorkspace("/tmp/large-project");
const logger = createTestLogger();
const infoSpy = vi.spyOn(logger, "info");
for (let index = 0; index < 150; index += 1) {
writeLegacyAgentJson({
paseoHome,
relativePath: `agents/large-project/agent-${index}.json`,
payload: createLegacyAgentJson({
id: `agent-${index}`,
cwd: "/tmp/large-project",
runtimeInfo: {
provider: "codex",
sessionId: `session-${index}`,
model: "gpt-5.1-codex-mini",
modeId: "plan",
},
}),
});
}
await importLegacyAgentSnapshots({
db: database.db,
paseoHome,
logger,
});
const batchLogs = infoSpy.mock.calls.filter(
([context, message]) =>
message === "Importing agent snapshot batch" && typeof context === "object",
);
expect(batchLogs.length).toBeGreaterThan(1);
expect(batchLogs[0]?.[0]).toMatchObject({
batch: 1,
totalBatches: batchLogs.length,
});
expect(batchLogs.at(-1)?.[0]).toMatchObject({
batch: batchLogs.length,
totalBatches: batchLogs.length,
rowsProcessed: 150,
});
});
});
function createLegacyAgentJson(overrides: Record<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",
});
}

View File

@@ -1,311 +0,0 @@
import path from "node:path";
import { execSync } from "node:child_process";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
import { detectWorkspaceGitMetadata } from "../workspace-git-metadata.js";
import { READ_ONLY_GIT_ENV } from "../checkout-git-utils.js";
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { toAgentSnapshotRowValues } from "./db-agent-snapshot-store.js";
import { agentSnapshots, projects, workspaces } from "./schema.js";
const SQLITE_MAX_VARIABLES_PER_STATEMENT = 999;
const AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW = Object.keys(agentSnapshots).length;
const MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT = Math.max(
1,
Math.floor(SQLITE_MAX_VARIABLES_PER_STATEMENT / AGENT_SNAPSHOT_INSERT_VARIABLES_PER_ROW),
);
export type LegacyAgentSnapshotImportResult =
| {
status: "imported";
importedAgents: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyAgentSnapshots(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<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 agentsDir = path.join(options.paseoHome, "agents");
if (!(await pathExists(agentsDir))) {
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
await backupLegacyAgentDirectory({
sourceDir: agentsDir,
paseoHome: options.paseoHome,
logger: options.logger,
});
const { records, skippedCount } = await readLegacyAgentRecords(agentsDir, options.logger);
if (skippedCount > 0) {
options.logger.warn({ skippedCount }, "Skipped invalid agent JSON files during migration");
}
if (records.length === 0) {
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
return {
status: "skipped",
reason: "no-legacy-files",
};
}
options.db.transaction((tx) => {
const workspaceRows = tx
.select({ id: workspaces.id, directory: workspaces.directory })
.from(workspaces)
.all();
const workspaceIdsByDirectory = new Map(
workspaceRows.map((row) => [row.directory, row.id] as const),
);
const projectRows = tx
.select({ id: projects.id, directory: projects.directory, gitRemote: projects.gitRemote })
.from(projects)
.all();
const projectIdsByDirectory = new Map(
projectRows.map((row) => [row.directory, row.id] as const),
);
const projectIdsByRemote = new Map(
projectRows
.filter((row): row is typeof row & { gitRemote: string } => row.gitRemote !== null)
.map((row) => [row.gitRemote, row.id] as const),
);
for (const record of records) {
const normalizedDirectory = normalizeWorkspaceId(record.cwd);
if (workspaceIdsByDirectory.has(normalizedDirectory)) {
continue;
}
const timestamp = record.updatedAt ?? record.createdAt;
const gitInfo = detectGitInfoForCwd(record.cwd);
const resolvedDirectory = gitInfo?.toplevel
? normalizeWorkspaceId(gitInfo.toplevel)
: normalizedDirectory;
const projectDisplayName =
gitInfo?.metadata.projectDisplayName ??
resolvedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ??
resolvedDirectory;
const projectKind = gitInfo?.metadata.projectKind ?? "directory";
const gitRemote = gitInfo?.metadata.gitRemote ?? null;
const workspaceKind = gitInfo?.metadata.isWorktree ? "worktree" : "checkout";
const workspaceDisplayName =
gitInfo?.metadata.workspaceDisplayName ??
normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ??
normalizedDirectory;
let projectId =
projectIdsByDirectory.get(resolvedDirectory) ??
(gitRemote !== null ? projectIdsByRemote.get(gitRemote) : undefined);
if (projectId === undefined) {
const projectRow = tx
.insert(projects)
.values({
directory: resolvedDirectory,
displayName: projectDisplayName,
kind: projectKind,
gitRemote,
createdAt: record.createdAt,
updatedAt: timestamp,
archivedAt: null,
})
.returning({ id: projects.id })
.get();
projectId = projectRow!.id;
projectIdsByDirectory.set(resolvedDirectory, projectId);
if (gitRemote !== null) {
projectIdsByRemote.set(gitRemote, projectId);
}
}
const workspaceRow = tx
.insert(workspaces)
.values({
projectId,
directory: normalizedDirectory,
displayName: workspaceDisplayName,
kind: workspaceKind,
createdAt: record.createdAt,
updatedAt: timestamp,
archivedAt: null,
})
.returning({ id: workspaces.id })
.get();
workspaceIdsByDirectory.set(normalizedDirectory, workspaceRow!.id);
}
const rows = records.flatMap((record) => {
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(record.cwd));
if (workspaceId === undefined) {
return [];
}
const clampedRecord =
record.lastStatus === "running" || record.lastStatus === "initializing"
? { ...record, lastStatus: "closed" as const }
: record;
return [toAgentSnapshotRowValues({ record: clampedRecord, workspaceId })];
});
const totalBatches = Math.ceil(rows.length / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
for (
let startIndex = 0;
startIndex < rows.length;
startIndex += MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT
) {
const batch = rows.slice(startIndex, startIndex + MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT);
const batchNum = Math.floor(startIndex / MAX_AGENT_SNAPSHOT_ROWS_PER_INSERT) + 1;
const rowsProcessed = startIndex + batch.length;
options.logger.info(
{ batch: batchNum, totalBatches, rowsProcessed },
"Importing agent snapshot batch",
);
tx.insert(agentSnapshots).values(batch).run();
}
});
options.logger.info(
{ importedAgents: records.length },
"Imported legacy agent snapshots into the database",
);
return {
status: "imported",
importedAgents: records.length,
};
}
async function readLegacyAgentRecords(
baseDir: string,
logger: Logger,
): Promise<{
records: StoredAgentRecord[];
skippedCount: number;
}> {
let entries: Array<import("node:fs").Dirent> = [];
try {
entries = await fs.readdir(baseDir, { withFileTypes: true });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return { records: [], skippedCount: 0 };
}
throw error;
}
const recordsById = new Map<string, StoredAgentRecord>();
let skippedCount = 0;
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith(".json")) {
const record = await readRecordFile(path.join(baseDir, entry.name), logger);
if (record) {
recordsById.set(record.id, record);
} else {
skippedCount += 1;
}
continue;
}
if (!entry.isDirectory()) {
continue;
}
let childEntries: Array<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);
} else {
skippedCount += 1;
}
}
}
return {
records: Array.from(recordsById.values()),
skippedCount,
};
}
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;
}
async function backupLegacyAgentDirectory(options: {
sourceDir: string;
paseoHome: string;
logger: Logger;
}): Promise<void> {
const backupPath = path.join(options.paseoHome, "backup", "pre-migration", "agents");
await fs.mkdir(path.dirname(backupPath), { recursive: true });
await fs.cp(options.sourceDir, backupPath, { recursive: true });
options.logger.info({ backupPath }, "Backed up legacy agent snapshots before migration");
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}
function detectGitInfoForCwd(
cwd: string,
): { toplevel: string; metadata: ReturnType<typeof detectWorkspaceGitMetadata> } | null {
try {
const toplevel = execSync("git rev-parse --show-toplevel", {
cwd,
env: READ_ONLY_GIT_ENV,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!toplevel) {
return null;
}
const directoryName = toplevel.split(/[\\/]/).filter(Boolean).at(-1) ?? toplevel;
const metadata = detectWorkspaceGitMetadata(cwd, directoryName);
return { toplevel, metadata };
} catch {
return null;
}
}

View File

@@ -1,154 +0,0 @@
/**
* Adhoc test: imports real legacy data from ~/.paseo into a fresh SQLite DB
* and asserts that projects/workspaces are properly grouped.
*
* Run with: npx vitest run packages/server/src/server/db/legacy-import-real-data.adhoc.test.ts
*/
import os from "node:os";
import path from "node:path";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js";
import { importLegacyAgentSnapshots } from "./legacy-agent-snapshot-import.js";
import { projects, workspaces, agentSnapshots } from "./schema.js";
import { eq } from "drizzle-orm";
const REAL_PASEO_HOME = path.join(os.homedir(), ".paseo");
describe("legacy import from real ~/.paseo data", () => {
let tmpDir: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-real-import-"));
dbDir = path.join(tmpDir, "db");
mkdirSync(dbDir, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database?.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("imports real data and groups projects correctly", async () => {
const logger = createTestLogger();
// Phase 1: Import legacy project/workspace JSON (has proper grouping)
const pwResult = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome: REAL_PASEO_HOME,
logger,
});
console.log("Project/workspace import result:", pwResult);
// Phase 2: Import legacy agent snapshots
const agentResult = await importLegacyAgentSnapshots({
db: database.db,
paseoHome: REAL_PASEO_HOME,
logger,
});
console.log("Agent snapshot import result:", agentResult);
// --- Assertions ---
const allProjects = await database.db.select().from(projects);
const allWorkspaces = await database.db.select().from(workspaces);
const allAgents = await database.db.select().from(agentSnapshots);
console.log(`Total projects: ${allProjects.length}`);
console.log(`Total workspaces: ${allWorkspaces.length}`);
console.log(`Total agents: ${allAgents.length}`);
// 1. There should be fewer projects than workspaces (workspaces group under projects)
expect(allProjects.length).toBeLessThan(allWorkspaces.length);
// 2. There should be exactly ONE project per unique git remote
const projectsByRemote = new Map<string, typeof allProjects>();
for (const project of allProjects) {
if (project.gitRemote) {
const existing = projectsByRemote.get(project.gitRemote) ?? [];
existing.push(project);
projectsByRemote.set(project.gitRemote, existing);
}
}
const duplicateRemotes: string[] = [];
for (const [remote, projectList] of projectsByRemote) {
if (projectList.length > 1) {
duplicateRemotes.push(remote);
console.log(`DUPLICATE: ${remote} has ${projectList.length} projects:`);
for (const p of projectList) {
console.log(` id=${p.id} directory=${p.directory}`);
}
}
}
expect(duplicateRemotes).toEqual([]);
// 3. Specifically: getpaseo/paseo should be ONE project
const paseoProjects = allProjects.filter(
(p) => p.gitRemote === "git@github.com:getpaseo/paseo.git",
);
expect(paseoProjects).toHaveLength(1);
const paseoProject = paseoProjects[0]!;
// 4. All paseo workspaces (worktrees + main checkout + subdirs) should be under that one project
const paseoWorkspaces = allWorkspaces.filter((w) => w.projectId === paseoProject.id);
console.log(`Paseo project id=${paseoProject.id}, directory=${paseoProject.directory}`);
console.log(`Paseo workspaces: ${paseoWorkspaces.length}`);
// The old data had ~51 paseo workspaces
expect(paseoWorkspaces.length).toBeGreaterThanOrEqual(10);
// 5. Subdirectory workspaces (packages/server, packages/app) should be under the same project
const subdirWorkspaces = paseoWorkspaces.filter((w) => w.directory.includes("/packages/"));
console.log(`Paseo subdirectory workspaces: ${subdirWorkspaces.length}`);
for (const w of subdirWorkspaces) {
console.log(` ${w.directory} (projectId=${w.projectId})`);
expect(w.projectId).toBe(paseoProject.id);
}
// 6. Worktree workspaces should be under the same project
const worktreeWorkspaces = paseoWorkspaces.filter((w) =>
w.directory.includes("/.paseo/worktrees/"),
);
console.log(`Paseo worktree workspaces: ${worktreeWorkspaces.length}`);
expect(worktreeWorkspaces.length).toBeGreaterThan(0);
// 7. No git project should be a subdirectory of another project with the SAME git remote
// (e.g., /dev/paseo/packages/server should not be its own project if /dev/paseo exists)
const activeGitProjects = allProjects.filter((p) => !p.archivedAt && p.gitRemote);
const subdirProjects: string[] = [];
for (const project of activeGitProjects) {
for (const other of activeGitProjects) {
if (
project.id !== other.id &&
project.gitRemote === other.gitRemote &&
project.directory.startsWith(other.directory + "/")
) {
subdirProjects.push(
`${project.directory} (id=${project.id}) is under ${other.directory} (id=${other.id}), both remote=${project.gitRemote}`,
);
}
}
}
if (subdirProjects.length > 0) {
console.log("Subdirectory git projects with same remote (should be empty):");
for (const s of subdirProjects) {
console.log(` ${s}`);
}
}
expect(subdirProjects).toEqual([]);
// 8. All agent snapshots should reference valid workspaces
for (const agent of allAgents) {
const workspace = allWorkspaces.find((w) => w.id === agent.workspaceId);
expect(workspace).toBeDefined();
}
});
});

View File

@@ -1,324 +0,0 @@
import os from "node:os";
import path from "node:path";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./sqlite-database.js";
import { importLegacyProjectWorkspaceJson } from "./legacy-project-workspace-import.js";
import { projects, workspaces } from "./schema.js";
describe("importLegacyProjectWorkspaceJson", () => {
let tmpDir: string;
let paseoHome: string;
let dbDir: string;
let database: PaseoDatabaseHandle;
beforeEach(async () => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-legacy-import-"));
paseoHome = path.join(tmpDir, ".paseo");
dbDir = path.join(paseoHome, "db");
mkdirSync(paseoHome, { recursive: true });
database = await openPaseoDatabase(dbDir);
});
afterEach(async () => {
await database?.close();
rmSync(tmpDir, { recursive: true, force: true });
});
test("imports legacy projects and workspaces once when the DB is empty", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedProjects: 1,
importedWorkspaces: 1,
});
const projectRows = await database.db.select().from(projects);
expect(projectRows).toEqual([
expect.objectContaining({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
}),
]);
expect(typeof projectRows[0]!.id).toBe("number");
const workspaceRows = await database.db.select().from(workspaces);
expect(workspaceRows).toEqual([
expect.objectContaining({
projectId: projectRows[0]!.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
}),
]);
});
test("skips import when the DB already has project or workspace data", async () => {
// Seed with a project in the new schema format
const [inserted] = await database.db
.insert(projects)
.values({
directory: "/tmp/existing-project",
kind: "git",
displayName: "Existing Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
})
.returning({ id: projects.id });
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "legacy-project",
rootPath: "/tmp/legacy-project",
kind: "git",
displayName: "Legacy Project",
createdAt: "2026-03-02T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "skipped",
reason: "database-not-empty",
});
// Only the existing project should be in DB
const allProjects = await database.db.select().from(projects);
expect(allProjects).toHaveLength(1);
expect(allProjects[0]!.id).toBe(inserted!.id);
});
test("rolls back the whole import when workspace insertion fails", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "missing-project",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
await expect(
importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
}),
).rejects.toThrow();
expect(await database.db.select().from(projects)).toEqual([]);
expect(await database.db.select().from(workspaces)).toEqual([]);
});
test("deduplicates projects with the same rootPath", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "First Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: null,
},
{
projectId: "project-2",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Replacement Project",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "project-2",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
archivedAt: null,
},
],
});
const result = await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
expect(result).toEqual({
status: "imported",
importedProjects: 1,
importedWorkspaces: 1,
});
const projectRows = await database.db.select().from(projects);
expect(projectRows).toHaveLength(1);
expect(projectRows[0]).toEqual(
expect.objectContaining({
directory: "/tmp/project-1",
displayName: "First Project",
}),
);
});
test("creates backup of JSON files before import", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: "/tmp/project-1",
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [
{
workspaceId: "workspace-1",
projectId: "project-1",
cwd: "/tmp/project-1",
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
});
await importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
});
const backupDir = path.join(paseoHome, "backup", "pre-migration");
const projectsBackupPath = path.join(backupDir, "projects.json");
const workspacesBackupPath = path.join(backupDir, "workspaces.json");
expect(existsSync(projectsBackupPath)).toBe(true);
expect(existsSync(workspacesBackupPath)).toBe(true);
expect(JSON.parse(readFileSync(projectsBackupPath, "utf8"))).toHaveLength(1);
expect(JSON.parse(readFileSync(workspacesBackupPath, "utf8"))).toHaveLength(1);
});
test("produces clear error message for corrupt project JSON", async () => {
writeLegacyJson({
paseoHome,
projectsJson: [
{
projectId: "project-1",
rootPath: 123,
kind: "git",
displayName: "Project One",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-02T00:00:00.000Z",
archivedAt: null,
},
],
workspacesJson: [],
});
await expect(
importLegacyProjectWorkspaceJson({
db: database.db,
paseoHome,
logger: createTestLogger(),
}),
).rejects.toThrow(
`Failed to parse ${path.join(paseoHome, "projects", "projects.json")}. ` +
"The file may be corrupted.",
);
});
});
function writeLegacyJson(input: {
paseoHome: string;
projectsJson: unknown[];
workspacesJson: unknown[];
}): void {
const projectsPath = path.join(input.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(input.paseoHome, "projects", "workspaces.json");
mkdirSync(path.dirname(projectsPath), { recursive: true });
writeFileSync(projectsPath, JSON.stringify(input.projectsJson, null, 2), {
encoding: "utf8",
flag: "w",
});
writeFileSync(workspacesPath, JSON.stringify(input.workspacesJson, null, 2), {
encoding: "utf8",
flag: "w",
});
}

View File

@@ -1,278 +0,0 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { count } from "drizzle-orm";
import type { Logger } from "pino";
import { z } from "zod";
import type { PaseoDatabaseHandle } from "./sqlite-database.js";
import { projects, workspaces } from "./schema.js";
const LEGACY_REMOTE_PREFIX = "remote:";
function deriveGitRemoteFromLegacyProjectId(projectId: string): string | null {
if (!projectId.startsWith(LEGACY_REMOTE_PREFIX)) {
return null;
}
const hostAndPath = projectId.slice(LEGACY_REMOTE_PREFIX.length);
return `git@${hostAndPath.replace("/", ":")}.git`;
}
// Legacy JSON schemas — these match the old pre-migration format
const LegacyProjectSchema = z.object({
projectId: z.string(),
rootPath: z.string(),
kind: z.string(),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
const LegacyWorkspaceSchema = z.object({
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
kind: z.string(),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
export type LegacyProjectWorkspaceImportResult =
| {
status: "imported";
importedProjects: number;
importedWorkspaces: number;
}
| {
status: "skipped";
reason: "database-not-empty" | "no-legacy-files";
};
export async function importLegacyProjectWorkspaceJson(options: {
db: PaseoDatabaseHandle["db"];
paseoHome: string;
logger: Logger;
}): Promise<LegacyProjectWorkspaceImportResult> {
const projectsPath = path.join(options.paseoHome, "projects", "projects.json");
const workspacesPath = path.join(options.paseoHome, "projects", "workspaces.json");
const databaseHasRows = await hasAnyProjectWorkspaceRows(options.db);
if (databaseHasRows) {
options.logger.info(
"Skipping legacy project/workspace JSON import because the DB is not empty",
);
return {
status: "skipped",
reason: "database-not-empty",
};
}
const [projectsExists, workspacesExists] = await Promise.all([
pathExists(projectsPath),
pathExists(workspacesPath),
]);
if (!projectsExists && !workspacesExists) {
options.logger.info(
"Skipping legacy project/workspace JSON import because no legacy files exist",
);
return {
status: "skipped",
reason: "no-legacy-files",
};
}
await backupLegacyProjectWorkspaceJson({
projectsPath,
workspacesPath,
paseoHome: options.paseoHome,
logger: options.logger,
});
const [projectRows, workspaceRows] = await Promise.all([
readLegacyProjects(projectsPath),
readLegacyWorkspaces(workspacesPath),
]);
if (projectRows.length === 0 && workspaceRows.length === 0) {
options.logger.info(
"Skipping legacy project/workspace JSON import because no legacy files exist",
);
return {
status: "skipped",
reason: "no-legacy-files",
};
}
// Deduplicate legacy projects by rootPath — prefer git over non_git
const deduplicatedProjects = new Map<string, (typeof projectRows)[number]>();
for (const legacy of projectRows) {
const existing = deduplicatedProjects.get(legacy.rootPath);
if (!existing || (legacy.kind === "git" && existing.kind !== "git")) {
deduplicatedProjects.set(legacy.rootPath, legacy);
}
}
options.db.transaction((tx) => {
// Insert projects, mapping old format to new schema
const projectDirectoryToId = new Map<string, number>();
for (const legacy of deduplicatedProjects.values()) {
const row = tx
.insert(projects)
.values({
directory: legacy.rootPath,
displayName: legacy.displayName,
kind: legacy.kind === "non_git" ? "directory" : legacy.kind,
gitRemote: deriveGitRemoteFromLegacyProjectId(legacy.projectId),
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
archivedAt: legacy.archivedAt,
})
.returning({ id: projects.id })
.get();
projectDirectoryToId.set(legacy.rootPath, row!.id);
}
// Build a map from legacy projectId -> new integer id
// Uses original projectRows so all duplicate projectIds resolve to the same new id
const legacyProjectIdToNewId = new Map<string, number>();
for (const legacy of projectRows) {
const newId = projectDirectoryToId.get(legacy.rootPath);
if (newId !== undefined) {
legacyProjectIdToNewId.set(legacy.projectId, newId);
}
}
// Insert workspaces, resolving project FK
for (const legacy of workspaceRows) {
const projectId = legacyProjectIdToNewId.get(legacy.projectId);
if (projectId === undefined) {
throw new Error(
`Legacy workspace ${legacy.workspaceId} references unknown project ${legacy.projectId}`,
);
}
tx.insert(workspaces)
.values({
projectId,
directory: legacy.cwd,
displayName: legacy.displayName,
kind:
legacy.kind === "local_checkout" || legacy.kind === "directory"
? "checkout"
: legacy.kind,
createdAt: legacy.createdAt,
updatedAt: legacy.updatedAt,
archivedAt: legacy.archivedAt,
})
.run();
}
});
const importedProjects = deduplicatedProjects.size;
options.logger.info(
{
importedProjects,
importedWorkspaces: workspaceRows.length,
},
"Imported legacy project/workspace JSON into the database",
);
return {
status: "imported",
importedProjects,
importedWorkspaces: workspaceRows.length,
};
}
async function readLegacyProjects(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
if (!raw) {
return [];
}
try {
return z.array(LegacyProjectSchema).parse(raw);
} catch (error) {
throw new Error(
`Failed to parse ${filePath}. The file may be corrupted. ` +
`Check the file and fix or remove invalid entries. ` +
`Original error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function readLegacyWorkspaces(filePath: string) {
const raw = await readOptionalJsonFile(filePath);
if (!raw) {
return [];
}
try {
return z.array(LegacyWorkspaceSchema).parse(raw);
} catch (error) {
throw new Error(
`Failed to parse ${filePath}. The file may be corrupted. ` +
`Check the file and fix or remove invalid entries. ` +
`Original error: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function readOptionalJsonFile(filePath: string): Promise<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;
}
async function backupLegacyProjectWorkspaceJson(options: {
projectsPath: string;
workspacesPath: string;
paseoHome: string;
logger: Logger;
}): Promise<void> {
const backupDir = path.join(options.paseoHome, "backup", "pre-migration");
await fs.mkdir(backupDir, { recursive: true });
if (await pathExists(options.projectsPath)) {
await fs.copyFile(options.projectsPath, path.join(backupDir, "projects.json"));
}
if (await pathExists(options.workspacesPath)) {
await fs.copyFile(options.workspacesPath, path.join(backupDir, "workspaces.json"));
}
options.logger.info(
{ backupPath: backupDir },
"Backed up legacy project/workspace JSON before migration",
);
}
async function pathExists(targetPath: string): Promise<boolean> {
try {
await fs.access(targetPath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return false;
}
throw error;
}
}

View File

@@ -1,12 +0,0 @@
import { fileURLToPath } from "node:url";
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const migrationsFolder = fileURLToPath(new URL("./migrations", import.meta.url));
export async function runPaseoDbMigrations(
db: BetterSQLite3Database<typeof import("./schema.js").paseoDbSchema>,
): Promise<void> {
await migrate(db, { migrationsFolder });
}

View File

@@ -1,61 +0,0 @@
CREATE TABLE `projects` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`directory` text NOT NULL,
`display_name` text NOT NULL,
`kind` text NOT NULL,
`git_remote` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`archived_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `projects_directory_unique` ON `projects` (`directory`);
--> statement-breakpoint
CREATE TABLE `workspaces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`project_id` integer NOT NULL,
`directory` text NOT NULL,
`display_name` text NOT NULL,
`kind` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`archived_at` text,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `workspaces_directory_unique` ON `workspaces` (`directory`);
--> statement-breakpoint
CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`);
--> statement-breakpoint
CREATE TABLE `agent_snapshots` (
`agent_id` text PRIMARY KEY NOT NULL,
`provider` text NOT NULL,
`workspace_id` integer NOT NULL,
`cwd` text NOT NULL,
`created_at` text NOT NULL,
`updated_at` text NOT NULL,
`last_activity_at` text,
`last_user_message_at` text,
`title` text,
`labels` text NOT NULL,
`last_status` text NOT NULL,
`last_mode_id` text,
`config` text,
`runtime_info` text,
`persistence` text,
`requires_attention` integer NOT NULL,
`attention_reason` text,
`attention_timestamp` text,
`internal` integer NOT NULL,
`archived_at` text,
FOREIGN KEY (`workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `agent_timeline_rows` (
`agent_id` text NOT NULL,
`seq` integer NOT NULL,
`committed_at` text NOT NULL,
`item` text NOT NULL,
`item_kind` text,
PRIMARY KEY(`agent_id`, `seq`)
);

View File

@@ -1,14 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"id": "7ccf4685-bd8f-41a6-bafb-44712c1fe0d7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {},
"views": {},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"checkConstraints": {}
}

View File

@@ -1,13 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1774405361702,
"tag": "0000_sqlite_initial",
"breakpoints": true
}
]
}

View File

@@ -1,80 +0,0 @@
import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core";
import type {
AgentPersistenceHandle,
AgentRuntimeInfo,
AgentTimelineItem,
} from "../agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
export const projects = sqliteTable("projects", {
id: integer("id").primaryKey({ autoIncrement: true }),
directory: text("directory").notNull().unique(),
displayName: text("display_name").notNull(),
kind: text("kind").notNull(),
gitRemote: text("git_remote"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
});
export const workspaces = sqliteTable(
"workspaces",
{
id: integer("id").primaryKey({ autoIncrement: true }),
projectId: integer("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
directory: text("directory").notNull().unique(),
displayName: text("display_name").notNull(),
kind: text("kind").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
archivedAt: text("archived_at"),
},
(table) => [index("workspaces_project_id_idx").on(table.projectId)],
);
export const agentSnapshots = sqliteTable("agent_snapshots", {
agentId: text("agent_id").primaryKey(),
provider: text("provider").notNull(),
workspaceId: integer("workspace_id")
.notNull()
.references(() => workspaces.id, { onDelete: "cascade" }),
cwd: text("cwd").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastActivityAt: text("last_activity_at"),
lastUserMessageAt: text("last_user_message_at"),
title: text("title"),
labels: text("labels", { mode: "json" }).$type<StoredAgentRecord["labels"]>().notNull(),
lastStatus: text("last_status").notNull(),
lastModeId: text("last_mode_id"),
config: text("config", { mode: "json" }).$type<StoredAgentRecord["config"]>(),
runtimeInfo: text("runtime_info", { mode: "json" }).$type<AgentRuntimeInfo>(),
persistence: text("persistence", { mode: "json" }).$type<AgentPersistenceHandle>(),
requiresAttention: integer("requires_attention", { mode: "boolean" }).notNull(),
attentionReason: text("attention_reason"),
attentionTimestamp: text("attention_timestamp"),
internal: integer("internal", { mode: "boolean" }).notNull(),
archivedAt: text("archived_at"),
});
export const agentTimelineRows = sqliteTable(
"agent_timeline_rows",
{
agentId: text("agent_id").notNull(),
seq: integer("seq").notNull(),
committedAt: text("committed_at").notNull(),
item: text("item", { mode: "json" }).$type<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,
};

View File

@@ -1,326 +0,0 @@
import os from "node:os";
import path from "node:path";
import { mkdtempSync, rmSync } from "node:fs";
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
import { openPaseoDatabase } from "./sqlite-database.js";
import { runPaseoDbMigrations } from "./migrations.js";
import { agentSnapshots, agentTimelineRows, projects, workspaces } from "./schema.js";
function createTimestamp(day: number): string {
return `2026-03-${String(day).padStart(2, "0")}T00:00:00.000Z`;
}
function createTimelineItem(type: AgentTimelineItem["type"], suffix: string): AgentTimelineItem {
if (type === "user_message") {
return { type, text: `user-${suffix}`, messageId: `msg-${suffix}` };
}
if (type === "assistant_message" || type === "reasoning") {
return { type, text: `${type}-${suffix}` };
}
return { type: "error", message: `error-${suffix}` };
}
describe("SQLite database contract", () => {
let tmpDir: string;
let dataDir: string;
beforeEach(() => {
tmpDir = mkdtempSync(path.join(os.tmpdir(), "paseo-db-"));
dataDir = path.join(tmpDir, "db");
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
test("creates, migrates, closes, and reopens a persistent database", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.close();
const reopened = await openPaseoDatabase(dataDir);
const rows = await reopened.db.select().from(projects);
expect(rows).toEqual([
{
id: project.id,
directory: "/tmp/project-1",
displayName: "Project One",
kind: "git",
gitRemote: "git@github.com:acme/project-1.git",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
},
]);
await reopened.close();
});
test("supports project and workspace linkage plus archive field updates", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.db
.update(workspaces)
.set({ archivedAt: createTimestamp(2), updatedAt: createTimestamp(2) })
.where(eq(workspaces.id, workspace.id));
const linkedRows = await database.db
.select({
projectId: projects.id,
workspaceId: workspaces.id,
workspaceArchivedAt: workspaces.archivedAt,
})
.from(workspaces)
.innerJoin(projects, eq(workspaces.projectId, projects.id));
expect(linkedRows).toEqual([
{
projectId: project.id,
workspaceId: workspace.id,
workspaceArchivedAt: createTimestamp(2),
},
]);
await database.close();
});
test("supports snapshot insert, get, update, and project-delete cascade with integer workspace IDs", async () => {
const database = await openPaseoDatabase(dataDir);
const [project] = await database.db
.insert(projects)
.values({
directory: "/tmp/project-1",
kind: "git",
displayName: "Project One",
gitRemote: null,
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
const [workspace] = await database.db
.insert(workspaces)
.values({
projectId: project.id,
directory: "/tmp/project-1",
kind: "checkout",
displayName: "main",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
archivedAt: null,
})
.returning();
await database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
workspaceId: workspace.id,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One",
labels: { surface: "workspace" },
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
});
await database.db
.update(agentSnapshots)
.set({
updatedAt: createTimestamp(2),
lastStatus: "running",
title: "Agent One Updated",
archivedAt: createTimestamp(3),
})
.where(eq(agentSnapshots.agentId, "agent-1"));
const rows = await database.db
.select()
.from(agentSnapshots)
.where(eq(agentSnapshots.agentId, "agent-1"));
expect(rows).toEqual([
{
agentId: "agent-1",
provider: "codex",
workspaceId: workspace.id,
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(2),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One Updated",
labels: { surface: "workspace" },
lastStatus: "running",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: createTimestamp(3),
},
]);
await database.db.delete(projects).where(eq(projects.id, project.id));
expect(await database.db.select().from(workspaces)).toEqual([]);
expect(await database.db.select().from(agentSnapshots)).toEqual([]);
await database.close();
});
test("rejects agent snapshots without a workspace ID", async () => {
const database = await openPaseoDatabase(dataDir);
await expect(
database.db.insert(agentSnapshots).values({
agentId: "agent-1",
provider: "codex",
cwd: "/tmp/project-1",
createdAt: createTimestamp(1),
updatedAt: createTimestamp(1),
lastActivityAt: createTimestamp(1),
lastUserMessageAt: null,
title: "Agent One",
labels: { surface: "workspace" },
lastStatus: "idle",
lastModeId: "plan",
config: { model: "gpt-5.1", modeId: "plan" },
runtimeInfo: { provider: "codex", sessionId: "session-1" },
persistence: { provider: "codex", sessionId: "session-1" },
requiresAttention: false,
attentionReason: null,
attentionTimestamp: null,
internal: false,
archivedAt: null,
} as typeof agentSnapshots.$inferInsert),
).rejects.toThrow();
await database.close();
});
test("supports timeline append and tail, after-seq, before-seq access patterns in committed order", async () => {
const database = await openPaseoDatabase(dataDir);
const rows = [1, 2, 3, 4].map((seq) => ({
agentId: "agent-1",
seq,
committedAt: createTimestamp(seq),
item: createTimelineItem(seq === 1 ? "user_message" : "assistant_message", String(seq)),
itemKind: seq === 1 ? "user_message" : "assistant_message",
}));
await database.db.insert(agentTimelineRows).values(rows);
const tailRows = await database.db
.select()
.from(agentTimelineRows)
.where(eq(agentTimelineRows.agentId, "agent-1"))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(tailRows.map((row) => row.seq).reverse()).toEqual([3, 4]);
const afterRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), gt(agentTimelineRows.seq, 2)))
.orderBy(asc(agentTimelineRows.seq));
expect(afterRows.map((row) => row.seq)).toEqual([3, 4]);
const beforeRows = await database.db
.select()
.from(agentTimelineRows)
.where(and(eq(agentTimelineRows.agentId, "agent-1"), lt(agentTimelineRows.seq, 4)))
.orderBy(desc(agentTimelineRows.seq))
.limit(2);
expect(beforeRows.map((row) => row.seq).reverse()).toEqual([2, 3]);
await database.close();
});
test("enforces per-agent seq uniqueness and reruns migrations without drift", async () => {
const database = await openPaseoDatabase(dataDir);
await database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(1),
item: createTimelineItem("assistant_message", "1"),
itemKind: "assistant_message",
});
await expect(
database.db.insert(agentTimelineRows).values({
agentId: "agent-1",
seq: 1,
committedAt: createTimestamp(2),
item: createTimelineItem("assistant_message", "duplicate"),
itemKind: "assistant_message",
}),
).rejects.toThrow();
await runPaseoDbMigrations(database.db);
const migrationRows = database.client
.prepare("select * from __drizzle_migrations order by created_at")
.all();
expect(migrationRows).toHaveLength(1);
await database.close();
});
});

View File

@@ -1,31 +0,0 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { runPaseoDbMigrations } from "./migrations.js";
import { paseoDbSchema } from "./schema.js";
export interface PaseoDatabaseHandle {
client: Database.Database;
db: BetterSQLite3Database<typeof paseoDbSchema>;
close(): Promise<void>;
}
export async function openPaseoDatabase(dataDir: string): Promise<PaseoDatabaseHandle> {
mkdirSync(dataDir, { recursive: true });
const databasePath = path.join(dataDir, "paseo.sqlite");
const client = new Database(databasePath);
client.pragma("foreign_keys = ON");
client.pragma("journal_mode = WAL");
const db = drizzle(client, { schema: paseoDbSchema });
await runPaseoDbMigrations(db);
return {
client,
db,
async close(): Promise<void> {
client.close();
},
};
}

View File

@@ -7,8 +7,6 @@ import { describe, expect, test, vi } from "vitest";
import { AgentLoadingService } from "./agent-loading-service.js";
import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
import { openPaseoDatabase } from "./db/sqlite-database.js";
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
@@ -57,65 +55,6 @@ function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>
}
describe("AgentLoadingService", () => {
test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-"));
const logger = pino({ level: "silent" });
const database = await openPaseoDatabase(path.join(workspaceRoot, "db"));
try {
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
const manager = new AgentManager({
clients: createTestAgentClients(),
registry: storage,
durableTimelineStore: new DbAgentTimelineStore(database.db),
logger,
idFactory: () => "00000000-0000-4000-8000-000000000301",
});
const service = new AgentLoadingService({
agentManager: manager as any,
agentStorage: storage as any,
logger,
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workspaceRoot,
model: "gpt-5.1-codex-mini",
});
await manager.runAgent(snapshot.id, "say 'timeline test'");
await manager.flush();
await storage.flush();
rmSync(
path.join(
os.tmpdir(),
"paseo-fake-provider-history",
"codex",
`${snapshot.persistence?.sessionId}.jsonl`,
),
{ force: true },
);
await manager.closeAgent(snapshot.id);
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
const durableTimeline = await manager.fetchTimeline(snapshot.id, {
direction: "tail",
limit: 0,
});
expect(loaded.id).toBe(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([]);
expect(durableTimeline.rows.every((row) => row.item.type === "assistant_message")).toBe(true);
expect(
durableTimeline.rows
.map((row) => (row.item.type === "assistant_message" ? row.item.text : ""))
.join(""),
).toBe("timeline test");
} finally {
await database.close();
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
const logger = pino({ level: "silent" });

View File

@@ -3,7 +3,7 @@ import { join } from "node:path";
import type { Logger } from "pino";
import { AgentManager } from "../agent/agent-manager.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
import type { AgentStorage } from "../agent/agent-storage.js";
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import {
@@ -98,7 +98,7 @@ export interface ScheduleServiceOptions {
paseoHome: string;
logger: Logger;
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
now?: () => Date;
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
}
@@ -107,7 +107,7 @@ export class ScheduleService {
private readonly store: ScheduleStore;
private readonly logger: Logger;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentSnapshotStore;
private readonly agentStorage: AgentStorage;
private readonly now: () => Date;
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
private readonly runningScheduleIds = new Set<string>();

View File

@@ -110,14 +110,24 @@ import type {
ProviderSnapshotEntry,
} from "./agent/agent-sdk-types.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
import type {
PersistedProjectRecord,
PersistedWorkspaceRecord,
ProjectRegistry,
WorkspaceRegistry,
import {
normalizeWorkspaceId as normalizePersistedWorkspaceId,
deriveWorkspaceId,
deriveProjectRootPath,
deriveProjectKind,
deriveWorkspaceKind,
deriveWorkspaceDisplayName,
buildProjectPlacementForCwd as buildProjectPlacementForCwdStandalone,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
import { AgentLoadingService } from "./agent-loading-service.js";
import {
@@ -156,7 +166,6 @@ import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
@@ -216,13 +225,13 @@ function clientSupportsFlexibleEditorIds(appVersion: string | null): boolean {
const MAX_TERMINAL_STREAM_SLOTS = 256;
type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
type DeleteFencedAgentStorage = AgentStorage & {
beginDelete(agentId: string): void;
};
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
function beginAgentDeleteIfSupported(agentStorage: AgentStorage, agentId: string): void {
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
(agentStorage as DeleteFencedAgentStorage).beginDelete(agentId);
}
}
@@ -382,13 +391,6 @@ interface AudioBufferState {
}
// Stub types for features under development (modules not yet available)
type BackgroundGitFetchManager = {
subscribe(
opts: { repoGitRoot: string; cwd: string },
callback: () => void,
): Promise<{ unsubscribe: () => void }>;
};
type AgentMcpTransportFactory = () => Promise<unknown>;
type VoiceTranscriptionResultPayload = {
@@ -414,7 +416,7 @@ export type SessionOptions = {
pushTokenStore: PushTokenStore;
paseoHome: string;
agentManager: AgentManager;
agentStorage: AgentSnapshotStore;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
chatService: FileBackedChatService;
@@ -422,7 +424,6 @@ export type SessionOptions = {
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
agentLoadingService?: AgentLoadingService;
backgroundGitFetchManager?: BackgroundGitFetchManager;
createAgentMcpTransport?: AgentMcpTransportFactory;
workspaceGitService: WorkspaceGitService;
daemonConfigStore: DaemonConfigStore;
@@ -624,7 +625,7 @@ export class Session {
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentSnapshotStore;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
@@ -1418,21 +1419,14 @@ export class Session {
private async findWorkspaceByDirectory(cwd: string): Promise<PersistedWorkspaceRecord | null> {
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const workspaces = await this.workspaceRegistry.list();
return workspaces.find((workspace) => workspace.directory === normalizedCwd) ?? null;
return workspaces.find((workspace) => workspace.cwd === normalizedCwd) ?? null;
}
/**
* Resolve a workspace ID that may be either a numeric ID (legacy) or a directory path
* (sent by clients that received the path-based descriptor format).
*/
private async resolveWorkspaceByIdOrDirectory(
workspaceId: string,
): Promise<PersistedWorkspaceRecord | null> {
const numericId = Number(workspaceId);
if (!Number.isNaN(numericId)) {
const record = await this.workspaceRegistry.get(numericId);
if (record) return record;
}
const record = await this.workspaceRegistry.get(workspaceId);
if (record) return record;
// Fallback: treat as directory path
return this.findWorkspaceByDirectory(workspaceId);
}
@@ -1455,12 +1449,12 @@ export class Session {
): Promise<ProjectPlacementPayload> {
const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
if (!project) {
throw new Error(`Project not found for workspace ${workspace.id}`);
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
}
const checkout =
project.kind !== "git"
? {
cwd: workspace.directory,
cwd: workspace.cwd,
isGit: false as const,
currentBranch: null,
remoteUrl: null,
@@ -1470,25 +1464,25 @@ export class Session {
}
: workspace.kind === "worktree"
? {
cwd: workspace.directory,
cwd: workspace.cwd,
isGit: true as const,
currentBranch: workspace.displayName,
remoteUrl: project.gitRemote,
worktreeRoot: workspace.directory,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: true as const,
mainRepoRoot: project.directory,
mainRepoRoot: project.rootPath,
}
: {
cwd: workspace.directory,
cwd: workspace.cwd,
isGit: true as const,
currentBranch: workspace.displayName,
remoteUrl: project.gitRemote,
worktreeRoot: workspace.directory,
remoteUrl: null,
worktreeRoot: workspace.cwd,
isPaseoOwnedWorktree: false as const,
mainRepoRoot: null,
};
return {
projectKey: String(project.id),
projectKey: project.projectId,
projectName: project.displayName,
checkout,
};
@@ -3071,12 +3065,12 @@ export class Session {
const snapshot = await this.agentManager.createAgent(
{
...sessionConfig,
cwd: resolvedWorkspace.directory,
cwd: resolvedWorkspace.cwd,
},
undefined,
{
labels,
workspaceId: resolvedWorkspace.id,
workspaceId: resolvedWorkspace.workspaceId,
initialPrompt: trimmedPrompt,
},
);
@@ -4494,10 +4488,10 @@ export class Session {
if (!persistedWorkspace) {
continue;
}
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.directory, {
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
isGit: workspace.projectKind === "git",
});
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.directory, workspace);
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace);
}
}
@@ -5014,7 +5008,7 @@ export class Session {
archiveWorkspaceRecord: async (workspaceDirectory) => {
const workspace = await this.findWorkspaceByDirectory(workspaceDirectory);
if (workspace) {
await this.archiveWorkspaceRecord(workspace.id);
await this.archiveWorkspaceRecord(workspace.workspaceId);
}
},
emit: (message) => this.emit(message),
@@ -5650,23 +5644,23 @@ export class Session {
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
let diffStat: { additions: number; deletions: number } | null = null;
const cachedShortstat = getCachedCheckoutShortstat(workspace.directory);
const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd);
if (cachedShortstat !== undefined) {
diffStat = cachedShortstat;
} else {
warmCheckoutShortstatInBackground(workspace.directory, undefined, () => {
void this.emitWorkspaceUpdateForCwd(workspace.directory);
warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => {
void this.emitWorkspaceUpdateForCwd(workspace.cwd);
});
}
return {
id: workspace.directory,
projectId: resolvedProjectRecord?.directory ?? workspace.directory,
id: workspace.cwd,
projectId: resolvedProjectRecord?.rootPath ?? workspace.cwd,
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
workspaceDirectory: workspace.directory,
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
workspaceDirectory: workspace.cwd,
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
workspaceKind: workspace.kind === "checkout" ? "local_checkout" : workspace.kind,
workspaceKind: workspace.kind,
name: workspace.displayName,
status: "done",
activityAt: null,
@@ -5674,7 +5668,7 @@ export class Session {
scripts:
this.scriptRouteStore && this.scriptRuntimeStore
? buildWorkspaceScriptPayloads({
workspaceDirectory: workspace.directory,
workspaceDirectory: workspace.cwd,
routeStore: this.scriptRouteStore,
runtimeStore: this.scriptRuntimeStore,
daemonPort: this.getDaemonTcpPort?.() ?? null,
@@ -5718,7 +5712,7 @@ export class Session {
projectRecord?: PersistedProjectRecord | null,
): Promise<WorkspaceDescriptorPayload> {
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
const snapshot = this.workspaceGitService.peekSnapshot(workspace.directory);
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
if (!snapshot) {
return base;
}
@@ -5754,7 +5748,7 @@ export class Session {
const activeProjects = new Map(
persistedProjects
.filter((project) => !project.archivedAt)
.map((project) => [project.id, project] as const),
.map((project) => [project.projectId, project] as const),
);
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
const workspaceIds = options.workspaceIds
@@ -5765,16 +5759,16 @@ export class Session {
)
: null;
const workspaceIdsByDirectory = new Map(
activeRecords.map((workspace) => [workspace.directory, workspace.directory] as const),
activeRecords.map((workspace) => [workspace.cwd, workspace.cwd] as const),
);
for (const workspace of activeRecords) {
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
if (workspaceIds && !workspaceIds.has(workspace.cwd)) {
continue;
}
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
descriptorsByWorkspaceId.set(
workspace.directory,
workspace.cwd,
await this.buildWorkspaceDescriptor({
workspace,
projectRecord,
@@ -5814,25 +5808,23 @@ export class Session {
workspaces: PersistedWorkspaceRecord[],
): string {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd);
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
if (exact) {
return exact.directory;
return exact.cwd;
}
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
const prefix = workspace.directory.endsWith(sep)
? workspace.directory
: `${workspace.directory}${sep}`;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
}
if (!bestMatch || workspace.directory.length > bestMatch.directory.length) {
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
bestMatch = workspace;
}
}
return bestMatch?.directory ?? normalizedCwd;
return bestMatch?.cwd ?? normalizedCwd;
}
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
@@ -6136,43 +6128,34 @@ export class Session {
return existingWorkspace;
}
const placement = await buildProjectPlacementForCwdStandalone({
cwd: normalizedCwd,
paseoHome: this.paseoHome,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const timestamp = new Date().toISOString();
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
let projectId: number | null = null;
if (gitMetadata.gitRemote) {
const existingProjects = await this.projectRegistry.list();
const matchingProject = existingProjects.find(
(p) => p.gitRemote === gitMetadata.gitRemote && !p.archivedAt,
);
if (matchingProject) {
projectId = matchingProject.id;
}
}
if (projectId === null) {
projectId = await this.projectRegistry.insert({
directory: normalizedCwd,
displayName: gitMetadata.projectDisplayName,
kind: gitMetadata.projectKind,
gitRemote: gitMetadata.gitRemote,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
});
}
const workspaceId = await this.workspaceRegistry.insert({
projectId,
directory: normalizedCwd,
displayName: gitMetadata.workspaceDisplayName,
kind: gitMetadata.isWorktree ? "worktree" : "checkout",
const projectRecord = createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({ cwd: normalizedCwd, checkout: placement.checkout }),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: timestamp,
updatedAt: timestamp,
archivedAt: null,
});
return (await this.workspaceRegistry.get(workspaceId))!;
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({ cwd: workspaceId, checkout: placement.checkout }),
createdAt: timestamp,
updatedAt: timestamp,
});
await this.workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
private async registerPendingWorktreeWorkspace(options: {
@@ -6187,48 +6170,42 @@ export class Session {
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
}
const projectId = Number(basePlacement.projectKey);
if (!Number.isInteger(projectId)) {
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
}
const projectId = basePlacement.projectKey;
const now = new Date().toISOString();
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
const workspaceId = await this.workspaceRegistry.insert({
const newRecord = createPersistedWorkspaceRecord({
workspaceId: workspaceDirectory,
projectId,
directory: workspaceDirectory,
cwd: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: now,
updatedAt: now,
archivedAt: null,
});
const workspace = await this.workspaceRegistry.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace not found after insert: ${workspaceId}`);
}
await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true });
return workspace;
await this.workspaceRegistry.upsert(newRecord);
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
return newRecord;
}
await this.workspaceRegistry.upsert({
id: existingWorkspace.id,
projectId,
directory: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: existingWorkspace.createdAt,
updatedAt: now,
archivedAt: null,
});
await this.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: existingWorkspace.workspaceId,
projectId,
cwd: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: existingWorkspace.createdAt,
updatedAt: now,
}),
);
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) =>
workspace.projectId === existingWorkspace.projectId &&
workspace.id !== existingWorkspace.id &&
workspace.workspaceId !== existingWorkspace.workspaceId &&
!workspace.archivedAt,
);
if (siblingWorkspaces.length === 0) {
@@ -6236,21 +6213,21 @@ export class Session {
}
}
return (await this.workspaceRegistry.get(existingWorkspace.id))!;
return (await this.workspaceRegistry.get(existingWorkspace.workspaceId))!;
}
private async archiveWorkspaceRecord(workspaceId: number, archivedAt?: string): Promise<void> {
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
if (!existingWorkspace || existingWorkspace.archivedAt) {
this.removeWorkspaceGitSubscription(String(workspaceId));
this.removeWorkspaceGitSubscription(workspaceId);
return;
}
const nextArchivedAt = archivedAt ?? new Date().toISOString();
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory);
this.removeWorkspaceGitSubscription(String(workspaceId));
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
this.removeWorkspaceGitSubscription(workspaceId);
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
@@ -6506,7 +6483,7 @@ export class Session {
): Promise<void> {
try {
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
await this.emitWorkspaceUpdateForCwd(workspace.directory);
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.emit({
type: "open_project_response",
@@ -6577,9 +6554,9 @@ export class Session {
}
const serviceResult = await spawnWorkspaceScript({
repoRoot: workspace.directory,
workspaceId: workspace.directory,
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
repoRoot: workspace.cwd,
workspaceId: workspace.cwd,
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
scriptName: request.scriptName,
daemonPort: this.getDaemonTcpPort?.() ?? null,
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
@@ -6588,11 +6565,11 @@ export class Session {
terminalManager: this.terminalManager,
logger: this.sessionLogger,
onLifecycleChanged: () => {
this.emitWorkspaceScriptStatusUpdate(workspace.directory);
this.emitWorkspaceScriptStatusUpdate(workspace.cwd);
},
});
this.emitWorkspaceScriptStatusUpdate(workspace.directory);
this.emitWorkspaceScriptStatusUpdate(workspace.cwd);
this.emit({
type: "start_workspace_script_response",
payload: {
@@ -6709,7 +6686,7 @@ export class Session {
private async runWorktreeSetupInBackground(options: {
requestCwd: string;
repoRoot: string;
workspaceId: number;
workspaceId: string;
worktree: { branchName: string; worktreePath: string };
shouldBootstrap: boolean;
slug: string;
@@ -6757,8 +6734,8 @@ export class Session {
throw new Error("Use worktree archive for Paseo worktrees");
}
const archivedAt = new Date().toISOString();
await this.archiveWorkspaceRecord(existing.id, archivedAt);
await this.emitWorkspaceUpdateForCwd(existing.directory);
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
await this.emitWorkspaceUpdateForCwd(existing.cwd);
this.emit({
type: "archive_workspace_response",
payload: {

View File

@@ -3,7 +3,7 @@ import type { Server as HTTPServer } from "http";
import { join } from "path";
import { hostname as getHostname } from "node:os";
import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
@@ -74,7 +74,6 @@ function createNoopProjectRegistry(): ProjectRegistry {
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
insert: async () => 0,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -87,7 +86,6 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
insert: async () => 0,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -234,7 +232,7 @@ export class VoiceAssistantWebSocketServer {
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentSnapshotStore;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
@@ -298,7 +296,7 @@ export class VoiceAssistantWebSocketServer {
logger: pino.Logger,
serverId: string,
agentManager: AgentManager,
agentStorage: AgentSnapshotStore,
agentStorage: AgentStorage,
downloadTokenStore: DownloadTokenStore,
paseoHome: string,
daemonConfigStore: DaemonConfigStore,

View File

@@ -11,17 +11,17 @@ import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
export type ReconciliationChange =
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
| {
kind: "project_updated";
projectId: number;
projectId: string;
directory: string;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">>;
}
| {
kind: "workspace_updated";
workspaceId: number;
workspaceId: string;
directory: string;
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
};
@@ -103,7 +103,7 @@ export class WorkspaceReconciliationService {
const activeProjects = allProjects.filter((p) => !p.archivedAt);
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
const workspacesByProject = new Map<number, PersistedWorkspaceRecord[]>();
const workspacesByProject = new Map<string, PersistedWorkspaceRecord[]>();
for (const workspace of activeWorkspaces) {
const list = workspacesByProject.get(workspace.projectId) ?? [];
list.push(workspace);
@@ -112,20 +112,20 @@ export class WorkspaceReconciliationService {
// 1. Archive workspaces whose directories no longer exist
for (const workspace of activeWorkspaces) {
if (!existsSync(workspace.directory)) {
if (!existsSync(workspace.cwd)) {
const timestamp = new Date().toISOString();
await this.workspaceRegistry.archive(workspace.id, timestamp);
await this.workspaceRegistry.archive(workspace.workspaceId, timestamp);
changes.push({
kind: "workspace_archived",
workspaceId: workspace.id,
directory: workspace.directory,
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
reason: "directory_missing",
});
// Update the in-memory list for the project orphan check below
const siblings = workspacesByProject.get(workspace.projectId);
if (siblings) {
const updated = siblings.filter((w) => w.id !== workspace.id);
const updated = siblings.filter((w) => w.workspaceId !== workspace.workspaceId);
workspacesByProject.set(workspace.projectId, updated);
}
}
@@ -133,14 +133,14 @@ export class WorkspaceReconciliationService {
// 2. Archive orphaned projects (all workspaces archived/removed)
for (const project of activeProjects) {
const siblings = workspacesByProject.get(project.id) ?? [];
const siblings = workspacesByProject.get(project.projectId) ?? [];
if (siblings.length === 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.archive(project.id, timestamp);
await this.projectRegistry.archive(project.projectId, timestamp);
changes.push({
kind: "project_archived",
projectId: project.id,
directory: project.directory,
projectId: project.projectId,
directory: project.rootPath,
reason: "no_active_workspaces",
});
}
@@ -149,23 +149,24 @@ export class WorkspaceReconciliationService {
// 3. Reconcile git metadata for active projects whose directories still exist
for (const project of activeProjects) {
if (project.archivedAt) continue;
const siblings = workspacesByProject.get(project.id) ?? [];
const siblings = workspacesByProject.get(project.projectId) ?? [];
if (siblings.length === 0) continue;
if (!existsSync(project.directory)) continue;
if (!existsSync(project.rootPath)) continue;
const directoryName =
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath;
const currentGit = detectWorkspaceGitMetadata(project.rootPath, directoryName);
const projectUpdates: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
> = {};
const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git";
// Detect kind change: directory → git
if (project.kind !== currentGit.projectKind) {
projectUpdates.kind = currentGit.projectKind;
if (project.kind !== mappedKind) {
projectUpdates.kind = mappedKind;
projectUpdates.displayName = currentGit.projectDisplayName;
projectUpdates.gitRemote = currentGit.gitRemote;
}
// Detect display name change (e.g. remote renamed)
@@ -177,15 +178,6 @@ export class WorkspaceReconciliationService {
projectUpdates.displayName = currentGit.projectDisplayName;
}
// Detect git remote change
if (
project.kind === "git" &&
currentGit.projectKind === "git" &&
project.gitRemote !== currentGit.gitRemote
) {
projectUpdates.gitRemote = currentGit.gitRemote;
}
if (Object.keys(projectUpdates).length > 0) {
const timestamp = new Date().toISOString();
await this.projectRegistry.upsert({
@@ -195,19 +187,18 @@ export class WorkspaceReconciliationService {
});
changes.push({
kind: "project_updated",
projectId: project.id,
directory: project.directory,
projectId: project.projectId,
directory: project.rootPath,
fields: projectUpdates,
});
}
// 4. Reconcile workspace display names (branch name changes)
for (const workspace of siblings) {
if (!existsSync(workspace.directory)) continue;
if (!existsSync(workspace.cwd)) continue;
const wsDirName =
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd;
const wsGit = detectWorkspaceGitMetadata(workspace.cwd, wsDirName);
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
const timestamp = new Date().toISOString();
@@ -218,8 +209,8 @@ export class WorkspaceReconciliationService {
});
changes.push({
kind: "workspace_updated",
workspaceId: workspace.id,
directory: workspace.directory,
workspaceId: workspace.workspaceId,
directory: workspace.cwd,
fields: { displayName: wsGit.workspaceDisplayName },
});
}

View File

@@ -0,0 +1,150 @@
import path from "node:path";
import type { Logger } from "pino";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import {
buildProjectPlacementForCwd,
deriveWorkspaceId,
deriveProjectKind,
deriveProjectRootPath,
deriveWorkspaceDisplayName,
deriveWorkspaceKind,
normalizeWorkspaceId,
} from "./workspace-registry-model.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
} from "./workspace-registry.js";
function minIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
}
if (!right) {
return left;
}
return Date.parse(left) <= Date.parse(right) ? left : right;
}
function maxIsoDate(left: string | null, right: string | null): string | null {
if (!left) {
return right;
}
if (!right) {
return left;
}
return Date.parse(left) >= Date.parse(right) ? left : right;
}
function resolveAgentCreatedAt(record: StoredAgentRecord): string {
return record.createdAt || record.updatedAt || new Date(0).toISOString();
}
function resolveAgentUpdatedAt(record: StoredAgentRecord): string {
return record.lastActivityAt || record.updatedAt || record.createdAt || new Date(0).toISOString();
}
export async function bootstrapWorkspaceRegistries(options: {
paseoHome: string;
agentStorage: AgentStorage;
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
logger: Logger;
}): Promise<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) {
return;
}
const records = await options.agentStorage.list();
const activeRecords = records.filter((record) => !record.archivedAt);
const recordsByWorkspaceId = new Map<
string,
{
placement: Awaited<ReturnType<typeof buildProjectPlacementForCwd>>;
records: StoredAgentRecord[];
}
>();
for (const record of activeRecords) {
const normalizedCwd = normalizeWorkspaceId(record.cwd);
const placement = await buildProjectPlacementForCwd({
cwd: normalizedCwd,
paseoHome: options.paseoHome,
});
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] };
existing.records.push(record);
recordsByWorkspaceId.set(workspaceId, existing);
}
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) {
const { placement, records: workspaceRecords } = entry;
let workspaceCreatedAt: string | null = null;
let workspaceUpdatedAt: string | null = null;
for (const record of workspaceRecords) {
workspaceCreatedAt = minIsoDate(workspaceCreatedAt, resolveAgentCreatedAt(record));
workspaceUpdatedAt = maxIsoDate(workspaceUpdatedAt, resolveAgentUpdatedAt(record));
}
const createdAt = workspaceCreatedAt ?? new Date().toISOString();
const updatedAt = workspaceUpdatedAt ?? createdAt;
await options.workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId,
projectId: placement.projectKey,
cwd: workspaceId,
kind: deriveWorkspaceKind(placement.checkout),
displayName: deriveWorkspaceDisplayName({
cwd: workspaceId,
checkout: placement.checkout,
}),
createdAt,
updatedAt,
}),
);
const existingProjectRange = projectRanges.get(placement.projectKey) ?? {
createdAt: null,
updatedAt: null,
};
existingProjectRange.createdAt = minIsoDate(existingProjectRange.createdAt, createdAt);
existingProjectRange.updatedAt = maxIsoDate(existingProjectRange.updatedAt, updatedAt);
projectRanges.set(placement.projectKey, existingProjectRange);
await options.projectRegistry.upsert(
createPersistedProjectRecord({
projectId: placement.projectKey,
rootPath: deriveProjectRootPath({
cwd: workspaceId,
checkout: placement.checkout,
}),
kind: deriveProjectKind(placement.checkout),
displayName: placement.projectName,
createdAt: existingProjectRange.createdAt ?? createdAt,
updatedAt: existingProjectRange.updatedAt ?? updatedAt,
}),
);
}
options.logger.info(
{
projectsFile: path.join(options.paseoHome, "projects", "projects.json"),
workspacesFile: path.join(options.paseoHome, "projects", "workspaces.json"),
materializedProjects: projectRanges.size,
materializedWorkspaces: recordsByWorkspaceId.size,
},
"Workspace registries bootstrapped from existing agent storage",
);
}

View File

@@ -1,7 +1,15 @@
import { resolve } from "node:path";
export type PersistedProjectKind = "git" | "directory";
export type PersistedWorkspaceKind = "checkout" | "worktree";
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
export type PersistedProjectKind = "git" | "non_git";
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
export type DetectStaleWorkspacesInput = {
activeWorkspaces: PersistedWorkspaceRecord[];
checkDirectoryExists: (cwd: string) => Promise<boolean>;
};
export function normalizeWorkspaceId(cwd: string): string {
const trimmed = cwd.trim();
@@ -10,3 +18,203 @@ export function normalizeWorkspaceId(cwd: string): string {
}
return resolve(trimmed);
}
export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string {
return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd);
}
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) {
return null;
}
const trimmed = remoteUrl.trim();
if (!trimmed) {
return null;
}
let host: string | null = null;
let remotePath: string | null = null;
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/);
if (scpLike) {
host = scpLike[1] ?? null;
remotePath = scpLike[2] ?? null;
} else if (trimmed.includes("://")) {
try {
const parsed = new URL(trimmed);
host = parsed.hostname || null;
remotePath = parsed.pathname ? parsed.pathname.replace(/^\/+/, "") : null;
} catch {
return null;
}
}
if (!host || !remotePath) {
return null;
}
let cleanedPath = remotePath.trim().replace(/^\/+/, "").replace(/\/+$/, "");
if (cleanedPath.endsWith(".git")) {
cleanedPath = cleanedPath.slice(0, -4);
}
if (!cleanedPath.includes("/")) {
return null;
}
const cleanedHost = host.toLowerCase();
if (cleanedHost === "github.com") {
return `remote:github.com/${cleanedPath}`;
}
return `remote:${cleanedHost}/${cleanedPath}`;
}
export function deriveProjectGroupingKey(options: {
cwd: string;
remoteUrl: string | null;
isPaseoOwnedWorktree: boolean;
mainRepoRoot: string | null;
}): string {
const remoteKey = deriveRemoteProjectKey(options.remoteUrl);
if (remoteKey) {
return remoteKey;
}
const mainRepoRoot = options.mainRepoRoot?.trim();
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
return mainRepoRoot;
}
return options.cwd;
}
export function deriveProjectGroupingName(projectKey: string): string {
const githubRemotePrefix = "remote:github.com/";
if (projectKey.startsWith(githubRemotePrefix)) {
return projectKey.slice(githubRemotePrefix.length) || projectKey;
}
const segments = projectKey.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] || projectKey;
}
function deriveWorkspaceDirectoryName(cwd: string): string {
const normalized = cwd.replace(/\\/g, "/");
const segments = normalized.split("/").filter(Boolean);
return segments[segments.length - 1] ?? cwd;
}
export function deriveWorkspaceDisplayName(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): string {
const branch = input.checkout.currentBranch?.trim() ?? null;
if (branch && branch.toUpperCase() !== "HEAD") {
return branch;
}
return deriveWorkspaceDirectoryName(input.cwd);
}
export function deriveProjectRootPath(input: {
cwd: string;
checkout: ProjectCheckoutLitePayload;
}): string {
if (input.checkout.isGit && input.checkout.isPaseoOwnedWorktree) {
return input.checkout.mainRepoRoot;
}
return input.cwd;
}
export function deriveProjectKind(checkout: ProjectCheckoutLitePayload): PersistedProjectKind {
return checkout.isGit ? "git" : "non_git";
}
export function deriveWorkspaceKind(checkout: ProjectCheckoutLitePayload): PersistedWorkspaceKind {
if (!checkout.isGit) {
return "directory";
}
return checkout.isPaseoOwnedWorktree ? "worktree" : "local_checkout";
}
export async function detectStaleWorkspaces(
input: DetectStaleWorkspacesInput,
): Promise<Set<string>> {
const staleWorkspaceIds = new Set<string>();
for (const workspace of input.activeWorkspaces) {
const dirExists = await input.checkDirectoryExists(workspace.cwd);
if (!dirExists) {
staleWorkspaceIds.add(workspace.workspaceId);
}
}
return staleWorkspaceIds;
}
export async function buildProjectPlacementForCwd(input: {
cwd: string;
paseoHome: string;
}): Promise<ProjectPlacementPayload> {
const normalizedCwd = normalizeWorkspaceId(input.cwd);
const checkout = await getCheckoutStatusLite(normalizedCwd, { paseoHome: input.paseoHome })
.then((status): ProjectCheckoutLitePayload => {
if (!status.isGit) {
return {
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: true,
mainRepoRoot: status.mainRepoRoot,
};
}
return {
cwd: normalizedCwd,
isGit: true,
currentBranch: status.currentBranch,
remoteUrl: status.remoteUrl,
worktreeRoot: status.worktreeRoot,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
})
.catch(
(): ProjectCheckoutLitePayload => ({
cwd: normalizedCwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
);
const projectKey = deriveProjectGroupingKey({
cwd: checkout.worktreeRoot ?? normalizedCwd,
remoteUrl: checkout.remoteUrl,
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
mainRepoRoot: checkout.mainRepoRoot,
});
return {
projectKey,
projectName: deriveProjectGroupingName(projectKey),
checkout,
};
}

View File

@@ -5,12 +5,12 @@ import path from "node:path";
import type { Logger } from "pino";
import {
parsePersistedProjectRecords,
parsePersistedWorkspaceRecords,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
type ProjectRegistry,
type WorkspaceRegistry,
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
} from "./workspace-registry.js";
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
@@ -18,9 +18,8 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
class FileBackedRegistry<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) => number;
private readonly schema: (record: unknown) => TRecord;
private readonly getId: (record: TRecord) => string;
private loaded = false;
private readonly cache = new Map<string, TRecord>();
private persistQueue: Promise<void> = Promise.resolve();
@@ -28,13 +27,12 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
constructor(options: {
filePath: string;
logger: Logger;
parseRecords: (input: unknown) => TRecord[];
getId: (record: TRecord) => number;
schema: (record: unknown) => TRecord;
getId: (record: TRecord) => string;
component: string;
}) {
this.filePath = options.filePath;
this.parseRecords = options.parseRecords;
this.parseRecord = (record) => options.parseRecords([record])[0]!;
this.schema = options.schema;
this.getId = options.getId;
this.logger = options.logger.child({
module: "workspace-registry",
@@ -60,47 +58,36 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
return Array.from(this.cache.values());
}
async get(id: number): Promise<TRecord | null> {
async get(id: string): Promise<TRecord | null> {
await this.load();
return this.cache.get(String(id)) ?? null;
}
async insert(record: Omit<TRecord, "id">): Promise<number> {
await this.load();
const nextId =
Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1;
const parsed = this.parseRecord({ ...record, id: nextId });
this.cache.set(String(this.getId(parsed)), parsed);
await this.enqueuePersist();
return nextId;
return this.cache.get(id) ?? null;
}
async upsert(record: TRecord): Promise<void> {
await this.load();
const parsed = this.parseRecord(record);
this.cache.set(String(this.getId(parsed)), parsed);
const parsed = this.schema(record);
this.cache.set(this.getId(parsed), parsed);
await this.enqueuePersist();
}
async archive(id: number, archivedAt: string): Promise<void> {
async archive(id: string, archivedAt: string): Promise<void> {
await this.load();
const key = String(id);
const existing = this.cache.get(key);
const existing = this.cache.get(id);
if (!existing) {
return;
}
const next = this.parseRecord({
const next = this.schema({
...existing,
updatedAt: archivedAt,
archivedAt,
});
this.cache.set(key, next);
this.cache.set(id, next);
await this.enqueuePersist();
}
async remove(id: number): Promise<void> {
async remove(id: string): Promise<void> {
await this.load();
if (!this.cache.delete(String(id))) {
if (!this.cache.delete(id)) {
return;
}
await this.enqueuePersist();
@@ -114,9 +101,10 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
this.cache.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = this.parseRecords(JSON.parse(raw));
const parsed = JSON.parse(raw) as TRecord[];
for (const record of parsed) {
this.cache.set(String(this.getId(record)), record);
const validated = this.schema(record);
this.cache.set(this.getId(validated), validated);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
@@ -150,8 +138,8 @@ export class FileBackedProjectRegistry
super({
filePath,
logger,
parseRecords: parsePersistedProjectRecords,
getId: (record) => record.id,
schema: (record) => createPersistedProjectRecord(record as PersistedProjectRecord),
getId: (record) => record.projectId,
component: "projects",
});
}
@@ -165,8 +153,8 @@ export class FileBackedWorkspaceRegistry
super({
filePath,
logger,
parseRecords: parsePersistedWorkspaceRecords,
getId: (record) => record.id,
schema: (record) => createPersistedWorkspaceRecord(record as PersistedWorkspaceRecord),
getId: (record) => record.workspaceId,
component: "workspaces",
});
}

View File

@@ -1,21 +1,27 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type { Logger } from "pino";
import { z } from "zod";
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
const PersistedProjectRecordSchema = z.object({
id: z.number().int(),
directory: z.string(),
kind: z.enum(["git", "directory"]),
projectId: z.string(),
rootPath: z.string(),
kind: z.enum(["git", "non_git"]),
displayName: z.string(),
gitRemote: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
archivedAt: z.string().nullable(),
});
const PersistedWorkspaceRecordSchema = z.object({
id: z.number().int(),
projectId: z.number().int(),
directory: z.string(),
kind: z.enum(["checkout", "worktree"]),
workspaceId: z.string(),
projectId: z.string(),
cwd: z.string(),
kind: z.enum(["local_checkout", "worktree", "directory"]),
displayName: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -25,58 +31,192 @@ const PersistedWorkspaceRecordSchema = z.object({
export type PersistedProjectRecord = z.infer<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>;
list(): Promise<PersistedProjectRecord[]>;
get(id: number): Promise<PersistedProjectRecord | null>;
insert(record: Omit<PersistedProjectRecord, "id">): Promise<number>;
get(projectId: string): Promise<PersistedProjectRecord | null>;
upsert(record: PersistedProjectRecord): Promise<void>;
archive(id: number, archivedAt: string): Promise<void>;
remove(id: number): Promise<void>;
archive(projectId: string, archivedAt: string): Promise<void>;
remove(projectId: string): Promise<void>;
}
export interface WorkspaceRegistry {
initialize(): Promise<void>;
existsOnDisk(): Promise<boolean>;
list(): Promise<PersistedWorkspaceRecord[]>;
get(id: number): Promise<PersistedWorkspaceRecord | null>;
insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number>;
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
upsert(record: PersistedWorkspaceRecord): Promise<void>;
archive(id: number, archivedAt: string): Promise<void>;
remove(id: number): Promise<void>;
archive(workspaceId: string, archivedAt: string): Promise<void>;
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: {
id: number;
directory: string;
kind: "git" | "directory";
projectId: string;
rootPath: string;
kind: PersistedProjectKind;
displayName: string;
gitRemote?: string | null;
createdAt: string;
updatedAt: string;
archivedAt?: string | null;
}): PersistedProjectRecord {
return PersistedProjectRecordSchema.parse({
...input,
gitRemote: input.gitRemote ?? null,
archivedAt: input.archivedAt ?? null,
});
}
export function createPersistedWorkspaceRecord(input: {
id: number;
projectId: number;
directory: string;
kind: "checkout" | "worktree";
workspaceId: string;
projectId: string;
cwd: string;
kind: PersistedWorkspaceKind;
displayName: string;
createdAt: string;
updatedAt: string;

View File

@@ -89,10 +89,10 @@ type ArchivePaseoWorktreeDependencies = {
type RegisterPendingWorktreeWorkspaceDependencies = {
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "insert" | "archive">;
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "archive">;
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "insert" | "list">;
archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise<void>;
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "list">;
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
};
type CreatePaseoWorktreeInBackgroundDependencies = {
@@ -102,7 +102,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
emit: EmitSessionMessage;
sessionLogger: Logger;
terminalManager: TerminalManager | null;
archiveWorkspaceRecord: (workspaceId: number) => Promise<void>;
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
};
type HandleWorkspaceSetupStatusRequestDependencies = {
@@ -126,7 +126,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
runWorktreeSetupInBackground: (options: {
requestCwd: string;
repoRoot: string;
workspaceId: number;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;
@@ -595,34 +595,33 @@ export async function registerPendingWorktreeWorkspace(
): Promise<PersistedWorkspaceRecord> {
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
const projectId = Number(basePlacement.projectKey);
if (!Number.isInteger(projectId)) {
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
}
const projectId = basePlacement.projectKey;
const now = new Date().toISOString();
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
if (!existingWorkspace) {
const workspaceId = await dependencies.workspaceRegistry.insert({
const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = {
workspaceId: workspaceDirectory,
projectId,
directory: workspaceDirectory,
cwd: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: now,
updatedAt: now,
archivedAt: null,
});
const workspace = await dependencies.workspaceRegistry.get(workspaceId);
};
await dependencies.workspaceRegistry.upsert(newRecord);
const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory);
if (!workspace) {
throw new Error(`Workspace not found after insert: ${workspaceId}`);
throw new Error(`Workspace not found after upsert: ${workspaceDirectory}`);
}
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
return workspace;
}
await dependencies.workspaceRegistry.upsert({
id: existingWorkspace.id,
workspaceId: existingWorkspace.workspaceId,
projectId,
directory: workspaceDirectory,
cwd: workspaceDirectory,
displayName: options.branchName,
kind: "worktree",
createdAt: existingWorkspace.createdAt,
@@ -635,7 +634,7 @@ export async function registerPendingWorktreeWorkspace(
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
}
return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!;
return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!;
}
export async function handleCreatePaseoWorktreeRequest(
@@ -710,7 +709,7 @@ export async function handleCreatePaseoWorktreeRequest(
void dependencies.runWorktreeSetupInBackground({
requestCwd: request.cwd,
repoRoot,
workspaceId: workspace.id,
workspaceId: workspace.workspaceId,
worktree: createdWorktree.worktree,
shouldBootstrap: createdWorktree.shouldBootstrap,
slug: normalizedSlug,
@@ -744,9 +743,9 @@ export async function handleWorkspaceSetupStatusRequest(
// Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup
if (!snapshot && Number.isNaN(Number(workspaceId))) {
const workspaces = await dependencies.workspaceRegistry.list();
const match = workspaces.find((w) => w.directory === workspaceId && !w.archivedAt);
const match = workspaces.find((w) => w.cwd === workspaceId && !w.archivedAt);
if (match) {
snapshot = dependencies.workspaceSetupSnapshots.get(String(match.id)) ?? null;
snapshot = dependencies.workspaceSetupSnapshots.get(match.workspaceId) ?? null;
}
}
@@ -765,7 +764,7 @@ export async function runWorktreeSetupInBackground(
options: {
requestCwd: string;
repoRoot: string;
workspaceId: number;
workspaceId: string;
worktree: WorktreeConfig;
shouldBootstrap: boolean;
slug: string;