mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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
1332
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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`)
|
||||
);
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774405361702,
|
||||
"tag": "0000_sqlite_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
150
packages/server/src/server/workspace-registry-bootstrap.ts
Normal file
150
packages/server/src/server/workspace-registry-bootstrap.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user