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.
|
|
||||||
Generated
+6
-1326
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,9 @@
|
|||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"scripts": {
|
"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",
|
"build:main": "tsc -p tsconfig.json",
|
||||||
"dev": "./scripts/dev.sh",
|
"dev": "./scripts/dev.sh",
|
||||||
"rebuild-native": "electron-rebuild -m ../.. -o better-sqlite3",
|
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
@@ -20,7 +19,6 @@
|
|||||||
"ws": "^8.14.2"
|
"ws": "^8.14.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron/rebuild": "^4.0.3",
|
|
||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "41.0.3",
|
"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": "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",
|
"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": "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');\"",
|
"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",
|
"prepack": "npm run build",
|
||||||
"start": "cross-env NODE_ENV=production node dist/server/server/index.js",
|
"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:download": "tsx scripts/download-speech-models.ts",
|
||||||
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
|
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
|
||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.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": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
"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",
|
"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",
|
"ai": "5.0.78",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"better-sqlite3": "^12.8.0",
|
|
||||||
"drizzle-orm": "^0.45.1",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-basic-auth": "^1.2.1",
|
"express-basic-auth": "^1.2.1",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -98,8 +95,6 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"@types/express": "^4.17.20",
|
"@types/express": "^4.17.20",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@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 { ManagedAgent } from "./agent/agent-manager.js";
|
||||||
import type { AgentManager } 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 { 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 {
|
import {
|
||||||
buildConfigOverrides,
|
buildConfigOverrides,
|
||||||
buildSessionConfig,
|
buildSessionConfig,
|
||||||
@@ -18,7 +18,7 @@ export type AgentLoadingServiceOptions = {
|
|||||||
AgentManager,
|
AgentManager,
|
||||||
"createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence"
|
"createAgent" | "getAgent" | "reloadAgentSession" | "resumeAgentFromPersistence"
|
||||||
>;
|
>;
|
||||||
agentStorage: Pick<AgentSnapshotStore, "get">;
|
agentStorage: Pick<AgentStorage, "get">;
|
||||||
logger: pino.Logger;
|
logger: pino.Logger;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from "../messages.js";
|
} from "../messages.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import {
|
import {
|
||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
@@ -64,7 +64,7 @@ import {
|
|||||||
|
|
||||||
export interface AgentManagementMcpOptions {
|
export interface AgentManagementMcpOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
getDaemonTcpPort?: () => number | null;
|
getDaemonTcpPort?: () => number | null;
|
||||||
scheduleService?: ScheduleService | null;
|
scheduleService?: ScheduleService | null;
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import { tmpdir } from "node:os";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
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 { AgentManager } from "./agent-manager.js";
|
||||||
import { AgentStorage } from "./agent-storage.js";
|
import { AgentStorage } from "./agent-storage.js";
|
||||||
import type {
|
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 {
|
class TestAgentClient implements AgentClient {
|
||||||
readonly provider = "codex" as const;
|
readonly provider = "codex" as const;
|
||||||
readonly capabilities = TEST_CAPABILITIES;
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
@@ -950,209 +915,64 @@ describe("AgentManager", () => {
|
|||||||
|
|
||||||
test("reloadAgentSession preserves current title when config title is unset", async () => {
|
test("reloadAgentSession preserves current title when config title is unset", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
|
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 storagePath = join(workdir, "agents");
|
||||||
const dataDir = join(workdir, "db");
|
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
const database = await openPaseoDatabase(dataDir);
|
const manager = new AgentManager({
|
||||||
let historyReplayCount = 0;
|
clients: {
|
||||||
let manager: AgentManager | null = null;
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => "00000000-0000-4000-8000-000000000126",
|
||||||
|
});
|
||||||
|
|
||||||
class HistoryReplayProbeSession extends TestAgentSession {
|
const snapshot = await manager.createAgent({
|
||||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
provider: "codex",
|
||||||
historyReplayCount += 1;
|
cwd: workdir,
|
||||||
yield {
|
});
|
||||||
type: "timeline",
|
await manager.setTitle(snapshot.id, "Generated title");
|
||||||
provider: this.provider,
|
|
||||||
item: { type: "assistant_message", text: "provider history replay" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class HistoryReplayProbeClient implements AgentClient {
|
const beforeReload = await storage.get(snapshot.id);
|
||||||
readonly provider = "codex" as const;
|
expect(beforeReload?.title).toBe("Generated title");
|
||||||
readonly capabilities = TEST_CAPABILITIES;
|
expect(beforeReload?.config?.title).toBeUndefined();
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
await manager.reloadAgentSession(snapshot.id);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
const afterReload = await storage.get(snapshot.id);
|
||||||
return new HistoryReplayProbeSession(config);
|
expect(afterReload?.title).toBe("Generated title");
|
||||||
}
|
expect(afterReload?.config?.title).toBeUndefined();
|
||||||
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setTitle bumps updatedAt and persists title in the same snapshot write", async () => {
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-set-title-updated-at-"));
|
||||||
const dataDir = join(workdir, "db");
|
const storagePath = join(workdir, "agents");
|
||||||
const database = await openPaseoDatabase(dataDir);
|
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 snapshot = await manager.createAgent({
|
||||||
const workspaceId = await seedWorkspace(database, { directory: workdir });
|
provider: "codex",
|
||||||
const storage = new DbAgentSnapshotStore(database.db);
|
cwd: workdir,
|
||||||
const manager = new AgentManager({
|
});
|
||||||
clients: {
|
|
||||||
codex: new TestAgentClient(),
|
|
||||||
},
|
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
idFactory: () => "00000000-0000-4000-8000-000000000127",
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = await manager.createAgent(
|
const before = await storage.get(snapshot.id);
|
||||||
{
|
expect(before).not.toBeNull();
|
||||||
provider: "codex",
|
|
||||||
cwd: workdir,
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ workspaceId },
|
|
||||||
);
|
|
||||||
|
|
||||||
const before = await storage.get(snapshot.id);
|
await manager.setTitle(snapshot.id, "Generated title");
|
||||||
expect(before).not.toBeNull();
|
|
||||||
|
|
||||||
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);
|
const live = manager.getAgent(snapshot.id);
|
||||||
expect(after?.title).toBe("Generated title");
|
expect(live).not.toBeNull();
|
||||||
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
|
expect(live!.updatedAt.getTime()).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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
|
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 () => {
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-rows-fallback-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -2079,41 +1838,29 @@ describe("AgentManager", () => {
|
|||||||
test("createAgent persists provided title before returning", async () => {
|
test("createAgent persists provided title before returning", async () => {
|
||||||
const agentId = "00000000-0000-4000-8000-000000000102";
|
const agentId = "00000000-0000-4000-8000-000000000102";
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const dataDir = join(workdir, "db");
|
const storagePath = join(workdir, "agents");
|
||||||
const database = await openPaseoDatabase(dataDir);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => agentId,
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
const snapshot = await manager.createAgent({
|
||||||
const workspaceId = await seedWorkspace(database, { directory: workdir });
|
provider: "codex",
|
||||||
const storage = new DbAgentSnapshotStore(database.db);
|
cwd: workdir,
|
||||||
const manager = new AgentManager({
|
title: "Fix Login Bug",
|
||||||
clients: {
|
});
|
||||||
codex: new TestAgentClient(),
|
|
||||||
},
|
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
idFactory: () => agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = await manager.createAgent(
|
expect(snapshot.id).toBe(agentId);
|
||||||
{
|
expect(snapshot.lifecycle).toBe("idle");
|
||||||
provider: "codex",
|
|
||||||
cwd: workdir,
|
|
||||||
title: "Fix Login Bug",
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
{ workspaceId },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(snapshot.id).toBe(agentId);
|
const persisted = await storage.get(agentId);
|
||||||
expect(snapshot.lifecycle).toBe("idle");
|
expect(persisted?.title).toBe("Fix Login Bug");
|
||||||
|
expect(persisted?.id).toBe(agentId);
|
||||||
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 });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createAgent populates runtimeInfo after session creation", async () => {
|
test("createAgent populates runtimeInfo after session creation", async () => {
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ import type {
|
|||||||
ListPersistedAgentsOptions,
|
ListPersistedAgentsOptions,
|
||||||
PersistedAgentDescriptor,
|
PersistedAgentDescriptor,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
|
||||||
import {
|
import {
|
||||||
InMemoryAgentTimelineStore,
|
InMemoryAgentTimelineStore,
|
||||||
type SeedAgentTimelineOptions,
|
type SeedAgentTimelineOptions,
|
||||||
@@ -92,7 +91,7 @@ export type ProviderAvailability = {
|
|||||||
export type AgentManagerOptions = {
|
export type AgentManagerOptions = {
|
||||||
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
clients?: Partial<Record<AgentProvider, AgentClient>>;
|
||||||
idFactory?: () => string;
|
idFactory?: () => string;
|
||||||
registry?: AgentSnapshotStore;
|
registry?: AgentStorage;
|
||||||
onAgentAttention?: AgentAttentionCallback;
|
onAgentAttention?: AgentAttentionCallback;
|
||||||
durableTimelineStore?: AgentTimelineStore;
|
durableTimelineStore?: AgentTimelineStore;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
@@ -306,7 +305,7 @@ export class AgentManager {
|
|||||||
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
||||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||||
private readonly idFactory: () => string;
|
private readonly idFactory: () => string;
|
||||||
private readonly registry?: AgentSnapshotStore;
|
private readonly registry?: AgentStorage;
|
||||||
private readonly durableTimelineStore?: AgentTimelineStore;
|
private readonly durableTimelineStore?: AgentTimelineStore;
|
||||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||||
@@ -605,7 +604,7 @@ export class AgentManager {
|
|||||||
agentId?: string,
|
agentId?: string,
|
||||||
options?: {
|
options?: {
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
workspaceId?: number;
|
workspaceId?: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
},
|
},
|
||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
@@ -1812,7 +1811,7 @@ export class AgentManager {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options?: {
|
options?: {
|
||||||
workspaceId?: number;
|
workspaceId?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
@@ -2093,7 +2092,7 @@ export class AgentManager {
|
|||||||
|
|
||||||
private async persistSnapshot(
|
private async persistSnapshot(
|
||||||
agent: ManagedAgent,
|
agent: ManagedAgent,
|
||||||
options?: { workspaceId?: number; title?: string | null; internal?: boolean },
|
options?: { workspaceId?: string; title?: string | null; internal?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!this.registry) {
|
if (!this.registry) {
|
||||||
return;
|
return;
|
||||||
@@ -2109,7 +2108,7 @@ export class AgentManager {
|
|||||||
await this.registry.applySnapshot(agent, options);
|
await this.registry.applySnapshot(agent, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireRegistry(): AgentSnapshotStore {
|
private requireRegistry(): AgentStorage {
|
||||||
if (!this.registry) {
|
if (!this.registry) {
|
||||||
throw new Error("Agent storage unavailable");
|
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 { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||||
@@ -84,7 +83,7 @@ export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
|||||||
return STORED_AGENT_SCHEMA.parse(value);
|
return STORED_AGENT_SCHEMA.parse(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AgentStorage implements AgentSnapshotStore {
|
export class AgentStorage {
|
||||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||||
private pathById: Map<string, string> = new Map();
|
private pathById: Map<string, string> = new Map();
|
||||||
private pathsById: Map<string, Set<string>> = new Map();
|
private pathsById: Map<string, Set<string>> = new Map();
|
||||||
@@ -182,10 +181,10 @@ export class AgentStorage implements AgentSnapshotStore {
|
|||||||
|
|
||||||
async applySnapshot(
|
async applySnapshot(
|
||||||
agent: ManagedAgent,
|
agent: ManagedAgent,
|
||||||
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
|
workspaceIdOrOptions?: string | { title?: string | null; internal?: boolean },
|
||||||
options?: { title?: string | null; internal?: boolean },
|
options?: { title?: string | null; internal?: boolean },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const nextOptions = typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
|
const nextOptions = typeof workspaceIdOrOptions === "string" ? options : workspaceIdOrOptions;
|
||||||
await this.load();
|
await this.load();
|
||||||
await this.waitForPendingWrite(agent.id);
|
await this.waitForPendingWrite(agent.id);
|
||||||
const existing = (await this.get(agent.id)) ?? null;
|
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 { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import { createAgentMcpServer } from "./mcp-server.js";
|
import { createAgentMcpServer } from "./mcp-server.js";
|
||||||
import type { AgentManager, ManagedAgent } from "./agent-manager.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";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
|
||||||
type TestDeps = {
|
type TestDeps = {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
spies: {
|
spies: {
|
||||||
agentManager: Record<string, any>;
|
agentManager: Record<string, any>;
|
||||||
agentStorage: Record<string, any>;
|
agentStorage: Record<string, any>;
|
||||||
@@ -46,7 +46,7 @@ function createTestDeps(): TestDeps {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
agentManager: agentManagerSpies as unknown as AgentManager,
|
agentManager: agentManagerSpies as unknown as AgentManager,
|
||||||
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
|
agentStorage: agentStorageSpies as unknown as AgentStorage,
|
||||||
spies: {
|
spies: {
|
||||||
agentManager: agentManagerSpies,
|
agentManager: agentManagerSpies,
|
||||||
agentStorage: agentStorageSpies,
|
agentStorage: agentStorageSpies,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "../messages.js";
|
} from "../messages.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import {
|
import {
|
||||||
appendTimelineItemIfAgentKnown,
|
appendTimelineItemIfAgentKnown,
|
||||||
emitLiveTimelineItemIfAgentKnown,
|
emitLiveTimelineItemIfAgentKnown,
|
||||||
@@ -47,7 +47,7 @@ import {
|
|||||||
|
|
||||||
export interface AgentMcpServerOptions {
|
export interface AgentMcpServerOptions {
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
getDaemonTcpPort?: () => number | null;
|
getDaemonTcpPort?: () => number | null;
|
||||||
scheduleService?: ScheduleService | null;
|
scheduleService?: ScheduleService | null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Logger } from "pino";
|
|||||||
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
|
import type { AgentPromptInput, AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.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 { serializeAgentSnapshot } from "../messages.js";
|
||||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||||
|
|
||||||
@@ -253,7 +253,7 @@ export function sanitizePermissionRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAgentTitle(
|
export async function resolveAgentTitle(
|
||||||
agentStorage: AgentSnapshotStore,
|
agentStorage: AgentStorage,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
@@ -267,7 +267,7 @@ export async function resolveAgentTitle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function serializeSnapshotWithMetadata(
|
export async function serializeSnapshotWithMetadata(
|
||||||
agentStorage: AgentSnapshotStore,
|
agentStorage: AgentStorage,
|
||||||
snapshot: ManagedAgent,
|
snapshot: ManagedAgent,
|
||||||
logger: Logger,
|
logger: Logger,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { Writable } from "node:stream";
|
import { Writable } from "node:stream";
|
||||||
import pino from "pino";
|
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 { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.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", () => {
|
describe("paseo daemon bootstrap", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -203,432 +199,4 @@ describe("paseo daemon bootstrap", () => {
|
|||||||
await rm(staticDir, { recursive: true, force: true });
|
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 type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.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 { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||||
import {
|
import {
|
||||||
buildProviderRegistry,
|
buildProviderRegistry,
|
||||||
createAllClients,
|
createAllClients,
|
||||||
shutdownProviders,
|
shutdownProviders,
|
||||||
} from "./agent/provider-registry.js";
|
} from "./agent/provider-registry.js";
|
||||||
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
|
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||||
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
|
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.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 { FileBackedChatService } from "./chat/chat-service.js";
|
import { FileBackedChatService } from "./chat/chat-service.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||||
import { LoopService } from "./loop-service.js";
|
import { LoopService } from "./loop-service.js";
|
||||||
@@ -201,7 +196,7 @@ export type PaseoDaemonConfig = {
|
|||||||
export interface PaseoDaemon {
|
export interface PaseoDaemon {
|
||||||
config: PaseoDaemonConfig;
|
config: PaseoDaemonConfig;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
terminalManager: TerminalManager;
|
terminalManager: TerminalManager;
|
||||||
scriptRouteStore: ScriptRouteStore;
|
scriptRouteStore: ScriptRouteStore;
|
||||||
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
|
scriptRuntimeStore: WorkspaceScriptRuntimeStore;
|
||||||
@@ -218,7 +213,6 @@ export async function createPaseoDaemon(
|
|||||||
const bootstrapStart = performance.now();
|
const bootstrapStart = performance.now();
|
||||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||||
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
||||||
let database: PaseoDatabaseHandle | null = null;
|
|
||||||
const daemonConfigStore = new DaemonConfigStore(
|
const daemonConfigStore = new DaemonConfigStore(
|
||||||
config.paseoHome,
|
config.paseoHome,
|
||||||
{
|
{
|
||||||
@@ -384,9 +378,6 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
const httpServer = createHTTPServer(app);
|
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
|
// Script proxy WebSocket upgrade handler — must be registered before the
|
||||||
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
// VoiceAssistantWebSocketServer attaches its own "upgrade" listener so that
|
||||||
// script-bound upgrades are forwarded first. The handler is a no-op for
|
// 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);
|
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({
|
const chatService = new FileBackedChatService({
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const durableTimelineStore = new DbAgentTimelineStore(database.db);
|
const agentManager = new AgentManager({
|
||||||
let agentManager: AgentManager | null = null;
|
|
||||||
const terminalManager = createTerminalManager();
|
|
||||||
agentManager = new AgentManager({
|
|
||||||
clients: {
|
clients: {
|
||||||
...createAllClients(logger, {
|
...createAllClients(logger, {
|
||||||
runtimeSettings: config.agentProviderSettings,
|
runtimeSettings: config.agentProviderSettings,
|
||||||
@@ -414,8 +410,6 @@ export async function createPaseoDaemon(
|
|||||||
...config.agentClients,
|
...config.agentClients,
|
||||||
},
|
},
|
||||||
registry: agentStorage,
|
registry: agentStorage,
|
||||||
durableTimelineStore,
|
|
||||||
terminalManager,
|
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
const providerRegistry = buildProviderRegistry(logger, {
|
const providerRegistry = buildProviderRegistry(logger, {
|
||||||
@@ -423,37 +417,23 @@ export async function createPaseoDaemon(
|
|||||||
providerOverrides: config.providerOverrides,
|
providerOverrides: config.providerOverrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectRegistry = new DbProjectRegistry(database.db);
|
const terminalManager = createTerminalManager();
|
||||||
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
|
|
||||||
|
|
||||||
try {
|
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||||
await importLegacyProjectWorkspaceJson({
|
logger,
|
||||||
db: database.db,
|
agentManager,
|
||||||
paseoHome: config.paseoHome,
|
agentStorage,
|
||||||
logger,
|
);
|
||||||
});
|
await agentStorage.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
|
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
||||||
} catch (err) {
|
await bootstrapWorkspaceRegistries({
|
||||||
logger.error({ err }, "Legacy project/workspace import failed (non-fatal)");
|
paseoHome: config.paseoHome,
|
||||||
}
|
agentStorage,
|
||||||
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({
|
|
||||||
projectRegistry,
|
projectRegistry,
|
||||||
workspaceRegistry,
|
workspaceRegistry,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
reconciliationService.start();
|
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||||
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
|
|
||||||
await chatService.initialize();
|
await chatService.initialize();
|
||||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||||
const checkoutDiffManager = new CheckoutDiffManager({
|
const checkoutDiffManager = new CheckoutDiffManager({
|
||||||
@@ -755,10 +735,11 @@ export async function createPaseoDaemon(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
reconciliationService.stop();
|
|
||||||
scriptHealthMonitor.stop();
|
scriptHealthMonitor.stop();
|
||||||
await closeAllAgents(logger, agentManager);
|
await closeAllAgents(logger, agentManager);
|
||||||
await agentManager.flush().catch(() => undefined);
|
await agentManager.flush().catch(() => undefined);
|
||||||
|
detachAgentStoragePersistence();
|
||||||
|
await agentStorage.flush().catch(() => undefined);
|
||||||
await shutdownProviders(logger, {
|
await shutdownProviders(logger, {
|
||||||
runtimeSettings: config.agentProviderSettings,
|
runtimeSettings: config.agentProviderSettings,
|
||||||
providerOverrides: config.providerOverrides,
|
providerOverrides: config.providerOverrides,
|
||||||
@@ -770,7 +751,6 @@ export async function createPaseoDaemon(
|
|||||||
if (wsServer) {
|
if (wsServer) {
|
||||||
await wsServer.close();
|
await wsServer.close();
|
||||||
}
|
}
|
||||||
await database?.close().catch(() => undefined);
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
httpServer.close(() => resolve());
|
httpServer.close(() => resolve());
|
||||||
});
|
});
|
||||||
@@ -792,7 +772,6 @@ export async function createPaseoDaemon(
|
|||||||
getListenTarget: () => boundListenTarget,
|
getListenTarget: () => boundListenTarget,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await database?.close().catch(() => undefined);
|
|
||||||
throw err;
|
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 { AgentLoadingService } from "./agent-loading-service.js";
|
||||||
import { AgentManager } from "./agent/agent-manager.js";
|
import { AgentManager } from "./agent/agent-manager.js";
|
||||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.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";
|
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||||
|
|
||||||
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
|
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
|
||||||
@@ -57,65 +55,6 @@ function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AgentLoadingService", () => {
|
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 () => {
|
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
|
||||||
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
|
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { AgentManager } from "../agent/agent-manager.js";
|
import { AgentManager } from "../agent/agent-manager.js";
|
||||||
import type { ManagedAgent } 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 type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||||
import { curateAgentActivity } from "../agent/activity-curator.js";
|
import { curateAgentActivity } from "../agent/activity-curator.js";
|
||||||
import {
|
import {
|
||||||
@@ -98,7 +98,7 @@ export interface ScheduleServiceOptions {
|
|||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ export class ScheduleService {
|
|||||||
private readonly store: ScheduleStore;
|
private readonly store: ScheduleStore;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentSnapshotStore;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly now: () => Date;
|
private readonly now: () => Date;
|
||||||
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
||||||
private readonly runningScheduleIds = new Set<string>();
|
private readonly runningScheduleIds = new Set<string>();
|
||||||
|
|||||||
@@ -110,14 +110,24 @@ import type {
|
|||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "./agent/agent-sdk-types.js";
|
} from "./agent/agent-sdk-types.js";
|
||||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||||
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
import { AGENT_PROVIDER_IDS } from "./agent/provider-manifest.js";
|
||||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
import {
|
||||||
import type {
|
normalizeWorkspaceId as normalizePersistedWorkspaceId,
|
||||||
PersistedProjectRecord,
|
deriveWorkspaceId,
|
||||||
PersistedWorkspaceRecord,
|
deriveProjectRootPath,
|
||||||
ProjectRegistry,
|
deriveProjectKind,
|
||||||
WorkspaceRegistry,
|
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";
|
} from "./workspace-registry.js";
|
||||||
import { AgentLoadingService } from "./agent-loading-service.js";
|
import { AgentLoadingService } from "./agent-loading-service.js";
|
||||||
import {
|
import {
|
||||||
@@ -156,7 +166,6 @@ import { expandTilde } from "../utils/path.js";
|
|||||||
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
|
||||||
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
import { READ_ONLY_GIT_ENV, toCheckoutError } from "./checkout-git-utils.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.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 type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.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;
|
const MAX_TERMINAL_STREAM_SLOTS = 256;
|
||||||
|
|
||||||
type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
|
type DeleteFencedAgentStorage = AgentStorage & {
|
||||||
beginDelete(agentId: string): void;
|
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") {
|
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)
|
// 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 AgentMcpTransportFactory = () => Promise<unknown>;
|
||||||
|
|
||||||
type VoiceTranscriptionResultPayload = {
|
type VoiceTranscriptionResultPayload = {
|
||||||
@@ -414,7 +416,7 @@ export type SessionOptions = {
|
|||||||
pushTokenStore: PushTokenStore;
|
pushTokenStore: PushTokenStore;
|
||||||
paseoHome: string;
|
paseoHome: string;
|
||||||
agentManager: AgentManager;
|
agentManager: AgentManager;
|
||||||
agentStorage: AgentSnapshotStore;
|
agentStorage: AgentStorage;
|
||||||
projectRegistry: ProjectRegistry;
|
projectRegistry: ProjectRegistry;
|
||||||
workspaceRegistry: WorkspaceRegistry;
|
workspaceRegistry: WorkspaceRegistry;
|
||||||
chatService: FileBackedChatService;
|
chatService: FileBackedChatService;
|
||||||
@@ -422,7 +424,6 @@ export type SessionOptions = {
|
|||||||
loopService: LoopService;
|
loopService: LoopService;
|
||||||
checkoutDiffManager: CheckoutDiffManager;
|
checkoutDiffManager: CheckoutDiffManager;
|
||||||
agentLoadingService?: AgentLoadingService;
|
agentLoadingService?: AgentLoadingService;
|
||||||
backgroundGitFetchManager?: BackgroundGitFetchManager;
|
|
||||||
createAgentMcpTransport?: AgentMcpTransportFactory;
|
createAgentMcpTransport?: AgentMcpTransportFactory;
|
||||||
workspaceGitService: WorkspaceGitService;
|
workspaceGitService: WorkspaceGitService;
|
||||||
daemonConfigStore: DaemonConfigStore;
|
daemonConfigStore: DaemonConfigStore;
|
||||||
@@ -624,7 +625,7 @@ export class Session {
|
|||||||
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
|
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
|
||||||
private agentTools: ToolSet | null = null;
|
private agentTools: ToolSet | null = null;
|
||||||
private agentManager: AgentManager;
|
private agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentSnapshotStore;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly projectRegistry: ProjectRegistry;
|
private readonly projectRegistry: ProjectRegistry;
|
||||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||||
private readonly chatService: FileBackedChatService;
|
private readonly chatService: FileBackedChatService;
|
||||||
@@ -1418,21 +1419,14 @@ export class Session {
|
|||||||
private async findWorkspaceByDirectory(cwd: string): Promise<PersistedWorkspaceRecord | null> {
|
private async findWorkspaceByDirectory(cwd: string): Promise<PersistedWorkspaceRecord | null> {
|
||||||
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
|
||||||
const workspaces = await this.workspaceRegistry.list();
|
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(
|
private async resolveWorkspaceByIdOrDirectory(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
): Promise<PersistedWorkspaceRecord | null> {
|
): Promise<PersistedWorkspaceRecord | null> {
|
||||||
const numericId = Number(workspaceId);
|
const record = await this.workspaceRegistry.get(workspaceId);
|
||||||
if (!Number.isNaN(numericId)) {
|
if (record) return record;
|
||||||
const record = await this.workspaceRegistry.get(numericId);
|
|
||||||
if (record) return record;
|
|
||||||
}
|
|
||||||
// Fallback: treat as directory path
|
// Fallback: treat as directory path
|
||||||
return this.findWorkspaceByDirectory(workspaceId);
|
return this.findWorkspaceByDirectory(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -1455,12 +1449,12 @@ export class Session {
|
|||||||
): Promise<ProjectPlacementPayload> {
|
): Promise<ProjectPlacementPayload> {
|
||||||
const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
const project = projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(`Project not found for workspace ${workspace.id}`);
|
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
||||||
}
|
}
|
||||||
const checkout =
|
const checkout =
|
||||||
project.kind !== "git"
|
project.kind !== "git"
|
||||||
? {
|
? {
|
||||||
cwd: workspace.directory,
|
cwd: workspace.cwd,
|
||||||
isGit: false as const,
|
isGit: false as const,
|
||||||
currentBranch: null,
|
currentBranch: null,
|
||||||
remoteUrl: null,
|
remoteUrl: null,
|
||||||
@@ -1470,25 +1464,25 @@ export class Session {
|
|||||||
}
|
}
|
||||||
: workspace.kind === "worktree"
|
: workspace.kind === "worktree"
|
||||||
? {
|
? {
|
||||||
cwd: workspace.directory,
|
cwd: workspace.cwd,
|
||||||
isGit: true as const,
|
isGit: true as const,
|
||||||
currentBranch: workspace.displayName,
|
currentBranch: workspace.displayName,
|
||||||
remoteUrl: project.gitRemote,
|
remoteUrl: null,
|
||||||
worktreeRoot: workspace.directory,
|
worktreeRoot: workspace.cwd,
|
||||||
isPaseoOwnedWorktree: true as const,
|
isPaseoOwnedWorktree: true as const,
|
||||||
mainRepoRoot: project.directory,
|
mainRepoRoot: project.rootPath,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
cwd: workspace.directory,
|
cwd: workspace.cwd,
|
||||||
isGit: true as const,
|
isGit: true as const,
|
||||||
currentBranch: workspace.displayName,
|
currentBranch: workspace.displayName,
|
||||||
remoteUrl: project.gitRemote,
|
remoteUrl: null,
|
||||||
worktreeRoot: workspace.directory,
|
worktreeRoot: workspace.cwd,
|
||||||
isPaseoOwnedWorktree: false as const,
|
isPaseoOwnedWorktree: false as const,
|
||||||
mainRepoRoot: null,
|
mainRepoRoot: null,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
projectKey: String(project.id),
|
projectKey: project.projectId,
|
||||||
projectName: project.displayName,
|
projectName: project.displayName,
|
||||||
checkout,
|
checkout,
|
||||||
};
|
};
|
||||||
@@ -3071,12 +3065,12 @@ export class Session {
|
|||||||
const snapshot = await this.agentManager.createAgent(
|
const snapshot = await this.agentManager.createAgent(
|
||||||
{
|
{
|
||||||
...sessionConfig,
|
...sessionConfig,
|
||||||
cwd: resolvedWorkspace.directory,
|
cwd: resolvedWorkspace.cwd,
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
labels,
|
labels,
|
||||||
workspaceId: resolvedWorkspace.id,
|
workspaceId: resolvedWorkspace.workspaceId,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -4494,10 +4488,10 @@ export class Session {
|
|||||||
if (!persistedWorkspace) {
|
if (!persistedWorkspace) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.directory, {
|
await this.syncWorkspaceGitWatchTarget(persistedWorkspace.cwd, {
|
||||||
isGit: workspace.projectKind === "git",
|
isGit: workspace.projectKind === "git",
|
||||||
});
|
});
|
||||||
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.directory, workspace);
|
this.rememberWorkspaceGitWatchFingerprint(persistedWorkspace.cwd, workspace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5014,7 +5008,7 @@ export class Session {
|
|||||||
archiveWorkspaceRecord: async (workspaceDirectory) => {
|
archiveWorkspaceRecord: async (workspaceDirectory) => {
|
||||||
const workspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
const workspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
await this.archiveWorkspaceRecord(workspace.id);
|
await this.archiveWorkspaceRecord(workspace.workspaceId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emit: (message) => this.emit(message),
|
emit: (message) => this.emit(message),
|
||||||
@@ -5650,23 +5644,23 @@ export class Session {
|
|||||||
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
projectRecord ?? (await this.projectRegistry.get(workspace.projectId));
|
||||||
|
|
||||||
let diffStat: { additions: number; deletions: number } | null = null;
|
let diffStat: { additions: number; deletions: number } | null = null;
|
||||||
const cachedShortstat = getCachedCheckoutShortstat(workspace.directory);
|
const cachedShortstat = getCachedCheckoutShortstat(workspace.cwd);
|
||||||
if (cachedShortstat !== undefined) {
|
if (cachedShortstat !== undefined) {
|
||||||
diffStat = cachedShortstat;
|
diffStat = cachedShortstat;
|
||||||
} else {
|
} else {
|
||||||
warmCheckoutShortstatInBackground(workspace.directory, undefined, () => {
|
warmCheckoutShortstatInBackground(workspace.cwd, undefined, () => {
|
||||||
void this.emitWorkspaceUpdateForCwd(workspace.directory);
|
void this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: workspace.directory,
|
id: workspace.cwd,
|
||||||
projectId: resolvedProjectRecord?.directory ?? workspace.directory,
|
projectId: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||||
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
||||||
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
|
projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd,
|
||||||
workspaceDirectory: workspace.directory,
|
workspaceDirectory: workspace.cwd,
|
||||||
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git",
|
||||||
workspaceKind: workspace.kind === "checkout" ? "local_checkout" : workspace.kind,
|
workspaceKind: workspace.kind,
|
||||||
name: workspace.displayName,
|
name: workspace.displayName,
|
||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
@@ -5674,7 +5668,7 @@ export class Session {
|
|||||||
scripts:
|
scripts:
|
||||||
this.scriptRouteStore && this.scriptRuntimeStore
|
this.scriptRouteStore && this.scriptRuntimeStore
|
||||||
? buildWorkspaceScriptPayloads({
|
? buildWorkspaceScriptPayloads({
|
||||||
workspaceDirectory: workspace.directory,
|
workspaceDirectory: workspace.cwd,
|
||||||
routeStore: this.scriptRouteStore,
|
routeStore: this.scriptRouteStore,
|
||||||
runtimeStore: this.scriptRuntimeStore,
|
runtimeStore: this.scriptRuntimeStore,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
@@ -5718,7 +5712,7 @@ export class Session {
|
|||||||
projectRecord?: PersistedProjectRecord | null,
|
projectRecord?: PersistedProjectRecord | null,
|
||||||
): Promise<WorkspaceDescriptorPayload> {
|
): Promise<WorkspaceDescriptorPayload> {
|
||||||
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
const base = await this.describeWorkspaceRecord(workspace, projectRecord);
|
||||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.directory);
|
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -5754,7 +5748,7 @@ export class Session {
|
|||||||
const activeProjects = new Map(
|
const activeProjects = new Map(
|
||||||
persistedProjects
|
persistedProjects
|
||||||
.filter((project) => !project.archivedAt)
|
.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 descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>();
|
||||||
const workspaceIds = options.workspaceIds
|
const workspaceIds = options.workspaceIds
|
||||||
@@ -5765,16 +5759,16 @@ export class Session {
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const workspaceIdsByDirectory = new Map(
|
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) {
|
for (const workspace of activeRecords) {
|
||||||
if (workspaceIds && !workspaceIds.has(workspace.directory)) {
|
if (workspaceIds && !workspaceIds.has(workspace.cwd)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
|
const projectRecord = activeProjects.get(workspace.projectId) ?? null;
|
||||||
descriptorsByWorkspaceId.set(
|
descriptorsByWorkspaceId.set(
|
||||||
workspace.directory,
|
workspace.cwd,
|
||||||
await this.buildWorkspaceDescriptor({
|
await this.buildWorkspaceDescriptor({
|
||||||
workspace,
|
workspace,
|
||||||
projectRecord,
|
projectRecord,
|
||||||
@@ -5814,25 +5808,23 @@ export class Session {
|
|||||||
workspaces: PersistedWorkspaceRecord[],
|
workspaces: PersistedWorkspaceRecord[],
|
||||||
): string {
|
): string {
|
||||||
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
|
||||||
const exact = workspaces.find((workspace) => workspace.directory === normalizedCwd);
|
const exact = workspaces.find((workspace) => workspace.cwd === normalizedCwd);
|
||||||
if (exact) {
|
if (exact) {
|
||||||
return exact.directory;
|
return exact.cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestMatch: PersistedWorkspaceRecord | null = null;
|
let bestMatch: PersistedWorkspaceRecord | null = null;
|
||||||
for (const workspace of workspaces) {
|
for (const workspace of workspaces) {
|
||||||
const prefix = workspace.directory.endsWith(sep)
|
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
|
||||||
? workspace.directory
|
|
||||||
: `${workspace.directory}${sep}`;
|
|
||||||
if (!normalizedCwd.startsWith(prefix)) {
|
if (!normalizedCwd.startsWith(prefix)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!bestMatch || workspace.directory.length > bestMatch.directory.length) {
|
if (!bestMatch || workspace.cwd.length > bestMatch.cwd.length) {
|
||||||
bestMatch = workspace;
|
bestMatch = workspace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return bestMatch?.directory ?? normalizedCwd;
|
return bestMatch?.cwd ?? normalizedCwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||||
@@ -6136,43 +6128,34 @@ export class Session {
|
|||||||
return existingWorkspace;
|
return existingWorkspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placement = await buildProjectPlacementForCwdStandalone({
|
||||||
|
cwd: normalizedCwd,
|
||||||
|
paseoHome: this.paseoHome,
|
||||||
|
});
|
||||||
|
const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout);
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
|
|
||||||
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
|
|
||||||
|
|
||||||
let projectId: number | null = null;
|
const projectRecord = createPersistedProjectRecord({
|
||||||
if (gitMetadata.gitRemote) {
|
projectId: placement.projectKey,
|
||||||
const existingProjects = await this.projectRegistry.list();
|
rootPath: deriveProjectRootPath({ cwd: normalizedCwd, checkout: placement.checkout }),
|
||||||
const matchingProject = existingProjects.find(
|
kind: deriveProjectKind(placement.checkout),
|
||||||
(p) => p.gitRemote === gitMetadata.gitRemote && !p.archivedAt,
|
displayName: placement.projectName,
|
||||||
);
|
|
||||||
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",
|
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: 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: {
|
private async registerPendingWorktreeWorkspace(options: {
|
||||||
@@ -6187,48 +6170,42 @@ export class Session {
|
|||||||
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
|
throw new Error(`Workspace not found for repo root ${options.repoRoot}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectId = Number(basePlacement.projectKey);
|
const projectId = basePlacement.projectKey;
|
||||||
if (!Number.isInteger(projectId)) {
|
|
||||||
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
const existingWorkspace = await this.findWorkspaceByDirectory(workspaceDirectory);
|
||||||
if (!existingWorkspace) {
|
if (!existingWorkspace) {
|
||||||
const workspaceId = await this.workspaceRegistry.insert({
|
const newRecord = createPersistedWorkspaceRecord({
|
||||||
|
workspaceId: workspaceDirectory,
|
||||||
projectId,
|
projectId,
|
||||||
directory: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
displayName: options.branchName,
|
displayName: options.branchName,
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
|
||||||
});
|
});
|
||||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
await this.workspaceRegistry.upsert(newRecord);
|
||||||
if (!workspace) {
|
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||||
throw new Error(`Workspace not found after insert: ${workspaceId}`);
|
return newRecord;
|
||||||
}
|
|
||||||
await this.syncWorkspaceGitWatchTarget(workspace.directory, { isGit: true });
|
|
||||||
return workspace;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.workspaceRegistry.upsert({
|
await this.workspaceRegistry.upsert(
|
||||||
id: existingWorkspace.id,
|
createPersistedWorkspaceRecord({
|
||||||
projectId,
|
workspaceId: existingWorkspace.workspaceId,
|
||||||
directory: workspaceDirectory,
|
projectId,
|
||||||
displayName: options.branchName,
|
cwd: workspaceDirectory,
|
||||||
kind: "worktree",
|
displayName: options.branchName,
|
||||||
createdAt: existingWorkspace.createdAt,
|
kind: "worktree",
|
||||||
updatedAt: now,
|
createdAt: existingWorkspace.createdAt,
|
||||||
archivedAt: null,
|
updatedAt: now,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
await this.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||||
|
|
||||||
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
||||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
(workspace) =>
|
(workspace) =>
|
||||||
workspace.projectId === existingWorkspace.projectId &&
|
workspace.projectId === existingWorkspace.projectId &&
|
||||||
workspace.id !== existingWorkspace.id &&
|
workspace.workspaceId !== existingWorkspace.workspaceId &&
|
||||||
!workspace.archivedAt,
|
!workspace.archivedAt,
|
||||||
);
|
);
|
||||||
if (siblingWorkspaces.length === 0) {
|
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);
|
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||||
if (!existingWorkspace || existingWorkspace.archivedAt) {
|
if (!existingWorkspace || existingWorkspace.archivedAt) {
|
||||||
this.removeWorkspaceGitSubscription(String(workspaceId));
|
this.removeWorkspaceGitSubscription(workspaceId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextArchivedAt = archivedAt ?? new Date().toISOString();
|
const nextArchivedAt = archivedAt ?? new Date().toISOString();
|
||||||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
|
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
|
||||||
await this.removeWorkspaceGitWatchTarget(existingWorkspace.directory);
|
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
|
||||||
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.directory);
|
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
|
||||||
this.removeWorkspaceGitSubscription(String(workspaceId));
|
this.removeWorkspaceGitSubscription(workspaceId);
|
||||||
|
|
||||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||||
@@ -6506,7 +6483,7 @@ export class Session {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
|
const workspace = await this.findOrCreateWorkspaceForDirectory(request.cwd);
|
||||||
await this.emitWorkspaceUpdateForCwd(workspace.directory);
|
await this.emitWorkspaceUpdateForCwd(workspace.cwd);
|
||||||
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "open_project_response",
|
type: "open_project_response",
|
||||||
@@ -6577,9 +6554,9 @@ export class Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serviceResult = await spawnWorkspaceScript({
|
const serviceResult = await spawnWorkspaceScript({
|
||||||
repoRoot: workspace.directory,
|
repoRoot: workspace.cwd,
|
||||||
workspaceId: workspace.directory,
|
workspaceId: workspace.cwd,
|
||||||
branchName: readGitCommand(workspace.directory, "git symbolic-ref --short HEAD"),
|
branchName: readGitCommand(workspace.cwd, "git symbolic-ref --short HEAD"),
|
||||||
scriptName: request.scriptName,
|
scriptName: request.scriptName,
|
||||||
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
daemonPort: this.getDaemonTcpPort?.() ?? null,
|
||||||
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
daemonListenHost: this.getDaemonTcpHost?.() ?? null,
|
||||||
@@ -6588,11 +6565,11 @@ export class Session {
|
|||||||
terminalManager: this.terminalManager,
|
terminalManager: this.terminalManager,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
onLifecycleChanged: () => {
|
onLifecycleChanged: () => {
|
||||||
this.emitWorkspaceScriptStatusUpdate(workspace.directory);
|
this.emitWorkspaceScriptStatusUpdate(workspace.cwd);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emitWorkspaceScriptStatusUpdate(workspace.directory);
|
this.emitWorkspaceScriptStatusUpdate(workspace.cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "start_workspace_script_response",
|
type: "start_workspace_script_response",
|
||||||
payload: {
|
payload: {
|
||||||
@@ -6709,7 +6686,7 @@ export class Session {
|
|||||||
private async runWorktreeSetupInBackground(options: {
|
private async runWorktreeSetupInBackground(options: {
|
||||||
requestCwd: string;
|
requestCwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
workspaceId: number;
|
workspaceId: string;
|
||||||
worktree: { branchName: string; worktreePath: string };
|
worktree: { branchName: string; worktreePath: string };
|
||||||
shouldBootstrap: boolean;
|
shouldBootstrap: boolean;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -6757,8 +6734,8 @@ export class Session {
|
|||||||
throw new Error("Use worktree archive for Paseo worktrees");
|
throw new Error("Use worktree archive for Paseo worktrees");
|
||||||
}
|
}
|
||||||
const archivedAt = new Date().toISOString();
|
const archivedAt = new Date().toISOString();
|
||||||
await this.archiveWorkspaceRecord(existing.id, archivedAt);
|
await this.archiveWorkspaceRecord(existing.workspaceId, archivedAt);
|
||||||
await this.emitWorkspaceUpdateForCwd(existing.directory);
|
await this.emitWorkspaceUpdateForCwd(existing.cwd);
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "archive_workspace_response",
|
type: "archive_workspace_response",
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Server as HTTPServer } from "http";
|
|||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { hostname as getHostname } from "node:os";
|
import { hostname as getHostname } from "node:os";
|
||||||
import type { AgentManager } from "./agent/agent-manager.js";
|
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 { DownloadTokenStore } from "./file-download/token-store.js";
|
||||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -74,7 +74,6 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
insert: async () => 0,
|
|
||||||
upsert: async () => {},
|
upsert: async () => {},
|
||||||
archive: async () => {},
|
archive: async () => {},
|
||||||
remove: async () => {},
|
remove: async () => {},
|
||||||
@@ -87,7 +86,6 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
|||||||
existsOnDisk: async () => true,
|
existsOnDisk: async () => true,
|
||||||
list: async () => [],
|
list: async () => [],
|
||||||
get: async () => null,
|
get: async () => null,
|
||||||
insert: async () => 0,
|
|
||||||
upsert: async () => {},
|
upsert: async () => {},
|
||||||
archive: async () => {},
|
archive: async () => {},
|
||||||
remove: async () => {},
|
remove: async () => {},
|
||||||
@@ -234,7 +232,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
private readonly serverId: string;
|
private readonly serverId: string;
|
||||||
private readonly daemonVersion: string;
|
private readonly daemonVersion: string;
|
||||||
private readonly agentManager: AgentManager;
|
private readonly agentManager: AgentManager;
|
||||||
private readonly agentStorage: AgentSnapshotStore;
|
private readonly agentStorage: AgentStorage;
|
||||||
private readonly projectRegistry: ProjectRegistry;
|
private readonly projectRegistry: ProjectRegistry;
|
||||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||||
private readonly chatService: FileBackedChatService;
|
private readonly chatService: FileBackedChatService;
|
||||||
@@ -298,7 +296,7 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
logger: pino.Logger,
|
logger: pino.Logger,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
agentManager: AgentManager,
|
agentManager: AgentManager,
|
||||||
agentStorage: AgentSnapshotStore,
|
agentStorage: AgentStorage,
|
||||||
downloadTokenStore: DownloadTokenStore,
|
downloadTokenStore: DownloadTokenStore,
|
||||||
paseoHome: string,
|
paseoHome: string,
|
||||||
daemonConfigStore: DaemonConfigStore,
|
daemonConfigStore: DaemonConfigStore,
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
|
|||||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
export type ReconciliationChange =
|
export type ReconciliationChange =
|
||||||
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
|
| { kind: "workspace_archived"; workspaceId: string; directory: string; reason: string }
|
||||||
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
|
| { kind: "project_archived"; projectId: string; directory: string; reason: string }
|
||||||
| {
|
| {
|
||||||
kind: "project_updated";
|
kind: "project_updated";
|
||||||
projectId: number;
|
projectId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
|
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: "workspace_updated";
|
kind: "workspace_updated";
|
||||||
workspaceId: number;
|
workspaceId: string;
|
||||||
directory: string;
|
directory: string;
|
||||||
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
|
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
|
||||||
};
|
};
|
||||||
@@ -103,7 +103,7 @@ export class WorkspaceReconciliationService {
|
|||||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.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) {
|
for (const workspace of activeWorkspaces) {
|
||||||
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||||
list.push(workspace);
|
list.push(workspace);
|
||||||
@@ -112,20 +112,20 @@ export class WorkspaceReconciliationService {
|
|||||||
|
|
||||||
// 1. Archive workspaces whose directories no longer exist
|
// 1. Archive workspaces whose directories no longer exist
|
||||||
for (const workspace of activeWorkspaces) {
|
for (const workspace of activeWorkspaces) {
|
||||||
if (!existsSync(workspace.directory)) {
|
if (!existsSync(workspace.cwd)) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
await this.workspaceRegistry.archive(workspace.id, timestamp);
|
await this.workspaceRegistry.archive(workspace.workspaceId, timestamp);
|
||||||
changes.push({
|
changes.push({
|
||||||
kind: "workspace_archived",
|
kind: "workspace_archived",
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.workspaceId,
|
||||||
directory: workspace.directory,
|
directory: workspace.cwd,
|
||||||
reason: "directory_missing",
|
reason: "directory_missing",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the in-memory list for the project orphan check below
|
// Update the in-memory list for the project orphan check below
|
||||||
const siblings = workspacesByProject.get(workspace.projectId);
|
const siblings = workspacesByProject.get(workspace.projectId);
|
||||||
if (siblings) {
|
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);
|
workspacesByProject.set(workspace.projectId, updated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,14 +133,14 @@ export class WorkspaceReconciliationService {
|
|||||||
|
|
||||||
// 2. Archive orphaned projects (all workspaces archived/removed)
|
// 2. Archive orphaned projects (all workspaces archived/removed)
|
||||||
for (const project of activeProjects) {
|
for (const project of activeProjects) {
|
||||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||||
if (siblings.length === 0) {
|
if (siblings.length === 0) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
await this.projectRegistry.archive(project.id, timestamp);
|
await this.projectRegistry.archive(project.projectId, timestamp);
|
||||||
changes.push({
|
changes.push({
|
||||||
kind: "project_archived",
|
kind: "project_archived",
|
||||||
projectId: project.id,
|
projectId: project.projectId,
|
||||||
directory: project.directory,
|
directory: project.rootPath,
|
||||||
reason: "no_active_workspaces",
|
reason: "no_active_workspaces",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -149,23 +149,24 @@ export class WorkspaceReconciliationService {
|
|||||||
// 3. Reconcile git metadata for active projects whose directories still exist
|
// 3. Reconcile git metadata for active projects whose directories still exist
|
||||||
for (const project of activeProjects) {
|
for (const project of activeProjects) {
|
||||||
if (project.archivedAt) continue;
|
if (project.archivedAt) continue;
|
||||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
const siblings = workspacesByProject.get(project.projectId) ?? [];
|
||||||
if (siblings.length === 0) continue;
|
if (siblings.length === 0) continue;
|
||||||
if (!existsSync(project.directory)) continue;
|
if (!existsSync(project.rootPath)) continue;
|
||||||
|
|
||||||
const directoryName =
|
const directoryName =
|
||||||
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
|
project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath;
|
||||||
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
|
const currentGit = detectWorkspaceGitMetadata(project.rootPath, directoryName);
|
||||||
|
|
||||||
const projectUpdates: Partial<
|
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
|
// Detect kind change: directory → git
|
||||||
if (project.kind !== currentGit.projectKind) {
|
if (project.kind !== mappedKind) {
|
||||||
projectUpdates.kind = currentGit.projectKind;
|
projectUpdates.kind = mappedKind;
|
||||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||||
projectUpdates.gitRemote = currentGit.gitRemote;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect display name change (e.g. remote renamed)
|
// Detect display name change (e.g. remote renamed)
|
||||||
@@ -177,15 +178,6 @@ export class WorkspaceReconciliationService {
|
|||||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
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) {
|
if (Object.keys(projectUpdates).length > 0) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
await this.projectRegistry.upsert({
|
await this.projectRegistry.upsert({
|
||||||
@@ -195,19 +187,18 @@ export class WorkspaceReconciliationService {
|
|||||||
});
|
});
|
||||||
changes.push({
|
changes.push({
|
||||||
kind: "project_updated",
|
kind: "project_updated",
|
||||||
projectId: project.id,
|
projectId: project.projectId,
|
||||||
directory: project.directory,
|
directory: project.rootPath,
|
||||||
fields: projectUpdates,
|
fields: projectUpdates,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Reconcile workspace display names (branch name changes)
|
// 4. Reconcile workspace display names (branch name changes)
|
||||||
for (const workspace of siblings) {
|
for (const workspace of siblings) {
|
||||||
if (!existsSync(workspace.directory)) continue;
|
if (!existsSync(workspace.cwd)) continue;
|
||||||
|
|
||||||
const wsDirName =
|
const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd;
|
||||||
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
|
const wsGit = detectWorkspaceGitMetadata(workspace.cwd, wsDirName);
|
||||||
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
|
|
||||||
|
|
||||||
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
|
if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) {
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
@@ -218,8 +209,8 @@ export class WorkspaceReconciliationService {
|
|||||||
});
|
});
|
||||||
changes.push({
|
changes.push({
|
||||||
kind: "workspace_updated",
|
kind: "workspace_updated",
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.workspaceId,
|
||||||
directory: workspace.directory,
|
directory: workspace.cwd,
|
||||||
fields: { displayName: wsGit.workspaceDisplayName },
|
fields: { displayName: wsGit.workspaceDisplayName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
export type PersistedProjectKind = "git" | "directory";
|
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
|
||||||
export type PersistedWorkspaceKind = "checkout" | "worktree";
|
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 {
|
export function normalizeWorkspaceId(cwd: string): string {
|
||||||
const trimmed = cwd.trim();
|
const trimmed = cwd.trim();
|
||||||
@@ -10,3 +18,203 @@ export function normalizeWorkspaceId(cwd: string): string {
|
|||||||
}
|
}
|
||||||
return resolve(trimmed);
|
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 type { Logger } from "pino";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parsePersistedProjectRecords,
|
|
||||||
parsePersistedWorkspaceRecords,
|
|
||||||
type PersistedProjectRecord,
|
type PersistedProjectRecord,
|
||||||
type PersistedWorkspaceRecord,
|
type PersistedWorkspaceRecord,
|
||||||
type ProjectRegistry,
|
type ProjectRegistry,
|
||||||
type WorkspaceRegistry,
|
type WorkspaceRegistry,
|
||||||
|
createPersistedProjectRecord,
|
||||||
|
createPersistedWorkspaceRecord,
|
||||||
} from "./workspace-registry.js";
|
} from "./workspace-registry.js";
|
||||||
|
|
||||||
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
||||||
@@ -18,9 +18,8 @@ type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
|||||||
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||||
private readonly filePath: string;
|
private readonly filePath: string;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
private readonly parseRecord: (record: unknown) => TRecord;
|
private readonly schema: (record: unknown) => TRecord;
|
||||||
private readonly parseRecords: (input: unknown) => TRecord[];
|
private readonly getId: (record: TRecord) => string;
|
||||||
private readonly getId: (record: TRecord) => number;
|
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
private readonly cache = new Map<string, TRecord>();
|
private readonly cache = new Map<string, TRecord>();
|
||||||
private persistQueue: Promise<void> = Promise.resolve();
|
private persistQueue: Promise<void> = Promise.resolve();
|
||||||
@@ -28,13 +27,12 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
|||||||
constructor(options: {
|
constructor(options: {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
parseRecords: (input: unknown) => TRecord[];
|
schema: (record: unknown) => TRecord;
|
||||||
getId: (record: TRecord) => number;
|
getId: (record: TRecord) => string;
|
||||||
component: string;
|
component: string;
|
||||||
}) {
|
}) {
|
||||||
this.filePath = options.filePath;
|
this.filePath = options.filePath;
|
||||||
this.parseRecords = options.parseRecords;
|
this.schema = options.schema;
|
||||||
this.parseRecord = (record) => options.parseRecords([record])[0]!;
|
|
||||||
this.getId = options.getId;
|
this.getId = options.getId;
|
||||||
this.logger = options.logger.child({
|
this.logger = options.logger.child({
|
||||||
module: "workspace-registry",
|
module: "workspace-registry",
|
||||||
@@ -60,47 +58,36 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
|||||||
return Array.from(this.cache.values());
|
return Array.from(this.cache.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: number): Promise<TRecord | null> {
|
async get(id: string): Promise<TRecord | null> {
|
||||||
await this.load();
|
await this.load();
|
||||||
return this.cache.get(String(id)) ?? null;
|
return this.cache.get(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(record: TRecord): Promise<void> {
|
async upsert(record: TRecord): Promise<void> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const parsed = this.parseRecord(record);
|
const parsed = this.schema(record);
|
||||||
this.cache.set(String(this.getId(parsed)), parsed);
|
this.cache.set(this.getId(parsed), parsed);
|
||||||
await this.enqueuePersist();
|
await this.enqueuePersist();
|
||||||
}
|
}
|
||||||
|
|
||||||
async archive(id: number, archivedAt: string): Promise<void> {
|
async archive(id: string, archivedAt: string): Promise<void> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const key = String(id);
|
const existing = this.cache.get(id);
|
||||||
const existing = this.cache.get(key);
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const next = this.parseRecord({
|
const next = this.schema({
|
||||||
...existing,
|
...existing,
|
||||||
updatedAt: archivedAt,
|
updatedAt: archivedAt,
|
||||||
archivedAt,
|
archivedAt,
|
||||||
});
|
});
|
||||||
this.cache.set(key, next);
|
this.cache.set(id, next);
|
||||||
await this.enqueuePersist();
|
await this.enqueuePersist();
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.load();
|
await this.load();
|
||||||
if (!this.cache.delete(String(id))) {
|
if (!this.cache.delete(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.enqueuePersist();
|
await this.enqueuePersist();
|
||||||
@@ -114,9 +101,10 @@ class FileBackedRegistry<TRecord extends RegistryRecord> {
|
|||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(this.filePath, "utf8");
|
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) {
|
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) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
@@ -150,8 +138,8 @@ export class FileBackedProjectRegistry
|
|||||||
super({
|
super({
|
||||||
filePath,
|
filePath,
|
||||||
logger,
|
logger,
|
||||||
parseRecords: parsePersistedProjectRecords,
|
schema: (record) => createPersistedProjectRecord(record as PersistedProjectRecord),
|
||||||
getId: (record) => record.id,
|
getId: (record) => record.projectId,
|
||||||
component: "projects",
|
component: "projects",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,8 +153,8 @@ export class FileBackedWorkspaceRegistry
|
|||||||
super({
|
super({
|
||||||
filePath,
|
filePath,
|
||||||
logger,
|
logger,
|
||||||
parseRecords: parsePersistedWorkspaceRecords,
|
schema: (record) => createPersistedWorkspaceRecord(record as PersistedWorkspaceRecord),
|
||||||
getId: (record) => record.id,
|
getId: (record) => record.workspaceId,
|
||||||
component: "workspaces",
|
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 { z } from "zod";
|
||||||
|
|
||||||
|
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
|
||||||
|
|
||||||
const PersistedProjectRecordSchema = z.object({
|
const PersistedProjectRecordSchema = z.object({
|
||||||
id: z.number().int(),
|
projectId: z.string(),
|
||||||
directory: z.string(),
|
rootPath: z.string(),
|
||||||
kind: z.enum(["git", "directory"]),
|
kind: z.enum(["git", "non_git"]),
|
||||||
displayName: z.string(),
|
displayName: z.string(),
|
||||||
gitRemote: z.string().nullable(),
|
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
archivedAt: z.string().nullable(),
|
archivedAt: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const PersistedWorkspaceRecordSchema = z.object({
|
const PersistedWorkspaceRecordSchema = z.object({
|
||||||
id: z.number().int(),
|
workspaceId: z.string(),
|
||||||
projectId: z.number().int(),
|
projectId: z.string(),
|
||||||
directory: z.string(),
|
cwd: z.string(),
|
||||||
kind: z.enum(["checkout", "worktree"]),
|
kind: z.enum(["local_checkout", "worktree", "directory"]),
|
||||||
displayName: z.string(),
|
displayName: z.string(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
updatedAt: z.string(),
|
updatedAt: z.string(),
|
||||||
@@ -25,58 +31,192 @@ const PersistedWorkspaceRecordSchema = z.object({
|
|||||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||||
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
|
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 {
|
export interface ProjectRegistry {
|
||||||
initialize(): Promise<void>;
|
initialize(): Promise<void>;
|
||||||
existsOnDisk(): Promise<boolean>;
|
existsOnDisk(): Promise<boolean>;
|
||||||
list(): Promise<PersistedProjectRecord[]>;
|
list(): Promise<PersistedProjectRecord[]>;
|
||||||
get(id: number): Promise<PersistedProjectRecord | null>;
|
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
||||||
insert(record: Omit<PersistedProjectRecord, "id">): Promise<number>;
|
|
||||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||||
archive(id: number, archivedAt: string): Promise<void>;
|
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||||
remove(id: number): Promise<void>;
|
remove(projectId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceRegistry {
|
export interface WorkspaceRegistry {
|
||||||
initialize(): Promise<void>;
|
initialize(): Promise<void>;
|
||||||
existsOnDisk(): Promise<boolean>;
|
existsOnDisk(): Promise<boolean>;
|
||||||
list(): Promise<PersistedWorkspaceRecord[]>;
|
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||||
get(id: number): Promise<PersistedWorkspaceRecord | null>;
|
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
|
||||||
insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number>;
|
|
||||||
upsert(record: PersistedWorkspaceRecord): Promise<void>;
|
upsert(record: PersistedWorkspaceRecord): Promise<void>;
|
||||||
archive(id: number, archivedAt: string): Promise<void>;
|
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
||||||
remove(id: number): 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: {
|
export function createPersistedProjectRecord(input: {
|
||||||
id: number;
|
projectId: string;
|
||||||
directory: string;
|
rootPath: string;
|
||||||
kind: "git" | "directory";
|
kind: PersistedProjectKind;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
gitRemote?: string | null;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt?: string | null;
|
archivedAt?: string | null;
|
||||||
}): PersistedProjectRecord {
|
}): PersistedProjectRecord {
|
||||||
return PersistedProjectRecordSchema.parse({
|
return PersistedProjectRecordSchema.parse({
|
||||||
...input,
|
...input,
|
||||||
gitRemote: input.gitRemote ?? null,
|
|
||||||
archivedAt: input.archivedAt ?? null,
|
archivedAt: input.archivedAt ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createPersistedWorkspaceRecord(input: {
|
export function createPersistedWorkspaceRecord(input: {
|
||||||
id: number;
|
workspaceId: string;
|
||||||
projectId: number;
|
projectId: string;
|
||||||
directory: string;
|
cwd: string;
|
||||||
kind: "checkout" | "worktree";
|
kind: PersistedWorkspaceKind;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ type ArchivePaseoWorktreeDependencies = {
|
|||||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||||
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
|
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>;
|
syncWorkspaceGitWatchTarget: (cwd: string, options: { isGit: boolean }) => Promise<void>;
|
||||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "insert" | "list">;
|
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "list">;
|
||||||
archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise<void>;
|
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CreatePaseoWorktreeInBackgroundDependencies = {
|
type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||||
@@ -102,7 +102,7 @@ type CreatePaseoWorktreeInBackgroundDependencies = {
|
|||||||
emit: EmitSessionMessage;
|
emit: EmitSessionMessage;
|
||||||
sessionLogger: Logger;
|
sessionLogger: Logger;
|
||||||
terminalManager: TerminalManager | null;
|
terminalManager: TerminalManager | null;
|
||||||
archiveWorkspaceRecord: (workspaceId: number) => Promise<void>;
|
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type HandleWorkspaceSetupStatusRequestDependencies = {
|
type HandleWorkspaceSetupStatusRequestDependencies = {
|
||||||
@@ -126,7 +126,7 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
|||||||
runWorktreeSetupInBackground: (options: {
|
runWorktreeSetupInBackground: (options: {
|
||||||
requestCwd: string;
|
requestCwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
workspaceId: number;
|
workspaceId: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
shouldBootstrap: boolean;
|
shouldBootstrap: boolean;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -595,34 +595,33 @@ export async function registerPendingWorktreeWorkspace(
|
|||||||
): Promise<PersistedWorkspaceRecord> {
|
): Promise<PersistedWorkspaceRecord> {
|
||||||
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
||||||
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
||||||
const projectId = Number(basePlacement.projectKey);
|
const projectId = basePlacement.projectKey;
|
||||||
if (!Number.isInteger(projectId)) {
|
|
||||||
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
|
|
||||||
}
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
|
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
|
||||||
if (!existingWorkspace) {
|
if (!existingWorkspace) {
|
||||||
const workspaceId = await dependencies.workspaceRegistry.insert({
|
const newRecord: import("./workspace-registry.js").PersistedWorkspaceRecord = {
|
||||||
|
workspaceId: workspaceDirectory,
|
||||||
projectId,
|
projectId,
|
||||||
directory: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
displayName: options.branchName,
|
displayName: options.branchName,
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
});
|
};
|
||||||
const workspace = await dependencies.workspaceRegistry.get(workspaceId);
|
await dependencies.workspaceRegistry.upsert(newRecord);
|
||||||
|
const workspace = await dependencies.workspaceRegistry.get(workspaceDirectory);
|
||||||
if (!workspace) {
|
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 });
|
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
await dependencies.workspaceRegistry.upsert({
|
await dependencies.workspaceRegistry.upsert({
|
||||||
id: existingWorkspace.id,
|
workspaceId: existingWorkspace.workspaceId,
|
||||||
projectId,
|
projectId,
|
||||||
directory: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
displayName: options.branchName,
|
displayName: options.branchName,
|
||||||
kind: "worktree",
|
kind: "worktree",
|
||||||
createdAt: existingWorkspace.createdAt,
|
createdAt: existingWorkspace.createdAt,
|
||||||
@@ -635,7 +634,7 @@ export async function registerPendingWorktreeWorkspace(
|
|||||||
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!;
|
return (await dependencies.workspaceRegistry.get(existingWorkspace.workspaceId))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleCreatePaseoWorktreeRequest(
|
export async function handleCreatePaseoWorktreeRequest(
|
||||||
@@ -710,7 +709,7 @@ export async function handleCreatePaseoWorktreeRequest(
|
|||||||
void dependencies.runWorktreeSetupInBackground({
|
void dependencies.runWorktreeSetupInBackground({
|
||||||
requestCwd: request.cwd,
|
requestCwd: request.cwd,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.workspaceId,
|
||||||
worktree: createdWorktree.worktree,
|
worktree: createdWorktree.worktree,
|
||||||
shouldBootstrap: createdWorktree.shouldBootstrap,
|
shouldBootstrap: createdWorktree.shouldBootstrap,
|
||||||
slug: normalizedSlug,
|
slug: normalizedSlug,
|
||||||
@@ -744,9 +743,9 @@ export async function handleWorkspaceSetupStatusRequest(
|
|||||||
// Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup
|
// Fallback: if workspaceId is a directory path, resolve to numeric ID and retry lookup
|
||||||
if (!snapshot && Number.isNaN(Number(workspaceId))) {
|
if (!snapshot && Number.isNaN(Number(workspaceId))) {
|
||||||
const workspaces = await dependencies.workspaceRegistry.list();
|
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) {
|
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: {
|
options: {
|
||||||
requestCwd: string;
|
requestCwd: string;
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
workspaceId: number;
|
workspaceId: string;
|
||||||
worktree: WorktreeConfig;
|
worktree: WorktreeConfig;
|
||||||
shouldBootstrap: boolean;
|
shouldBootstrap: boolean;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user