mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'storage-terminal-ui-dev' into dev
# Conflicts: # nix/package.nix # packages/app/src/app/_layout.tsx # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/hooks/use-command-center.ts # packages/app/src/screens/agent/draft-agent-screen.tsx # packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx # packages/app/src/screens/workspace/workspace-screen.tsx # packages/server/src/server/session.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/terminal/terminal.test.ts # packages/server/src/terminal/terminal.ts
This commit is contained in:
9
packages/server/drizzle.config.ts
Normal file
9
packages/server/drizzle.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./packages/server/src/server/db/schema.ts",
|
||||
out: "./packages/server/src/server/db/migrations",
|
||||
dialect: "sqlite",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "NODE_ENV=production node dist/server/server/index.js",
|
||||
@@ -45,6 +45,7 @@
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"db:query": "tsx scripts/db-query.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
@@ -73,6 +74,8 @@
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.17.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-uri": "^3.1.0",
|
||||
@@ -96,6 +99,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
140
packages/server/scripts/db-query.ts
Normal file
140
packages/server/scripts/db-query.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/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();
|
||||
@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_invalid",
|
||||
|
||||
@@ -119,9 +119,9 @@ export type DaemonEvent =
|
||||
agentId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
| {
|
||||
type: "workspace_update";
|
||||
workspaceId: string;
|
||||
workspaceId: number;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
@@ -130,7 +130,6 @@ export type DaemonEvent =
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: string;
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
}
|
||||
| { type: "status"; payload: { status: string } & Record<string, unknown> }
|
||||
| { type: "agent_deleted"; agentId: string }
|
||||
@@ -182,6 +181,7 @@ export type CreateAgentRequestOptions = {
|
||||
config?: AgentSessionConfig;
|
||||
provider?: AgentProvider;
|
||||
cwd?: string;
|
||||
workspaceId?: number;
|
||||
initialPrompt?: string;
|
||||
clientMessageId?: string;
|
||||
outputSchema?: Record<string, unknown>;
|
||||
@@ -320,13 +320,13 @@ type ScheduleDeletePayload = Extract<
|
||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||
|
||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
||||
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
|
||||
export type FetchAgentTimelineCursor = NonNullable<
|
||||
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
|
||||
>;
|
||||
export type FetchAgentTimelineOptions = {
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
@@ -1301,7 +1301,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async archiveWorkspace(
|
||||
workspaceId: string,
|
||||
workspaceId: number,
|
||||
requestId?: string,
|
||||
): Promise<ArchiveWorkspacePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -1386,6 +1386,7 @@ export class DaemonClient {
|
||||
type: "create_agent_request",
|
||||
requestId,
|
||||
config,
|
||||
...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}),
|
||||
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
|
||||
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
|
||||
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
|
||||
@@ -1576,7 +1577,6 @@ export class DaemonClient {
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
||||
...(options.projection ? { projection: options.projection } : {}),
|
||||
});
|
||||
|
||||
const payload = await this.sendRequest({
|
||||
@@ -2733,12 +2733,16 @@ export class DaemonClient {
|
||||
cwd: string,
|
||||
name?: string,
|
||||
requestId?: string,
|
||||
options?: { agentId?: string; command?: string; args?: string[] },
|
||||
): Promise<CreateTerminalPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "create_terminal_request",
|
||||
cwd,
|
||||
name,
|
||||
agentId: options?.agentId,
|
||||
command: options?.command,
|
||||
args: options?.args,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
@@ -3552,7 +3556,6 @@ export class DaemonClient {
|
||||
event: msg.payload.event,
|
||||
timestamp: msg.payload.timestamp,
|
||||
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
|
||||
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
|
||||
};
|
||||
case "status":
|
||||
return { type: "status", payload: msg.payload };
|
||||
@@ -3654,6 +3657,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
||||
config,
|
||||
provider,
|
||||
cwd,
|
||||
workspaceId: _workspaceId,
|
||||
initialPrompt: _initialPrompt,
|
||||
images: _images,
|
||||
git: _git,
|
||||
|
||||
128
packages/server/src/server/agent-loading-service.ts
Normal file
128
packages/server/src/server/agent-loading-service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
toAgentPersistenceHandle,
|
||||
} from "./persistence-hooks.js";
|
||||
|
||||
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
|
||||
|
||||
export type AgentLoadingServiceOptions = {
|
||||
agentManager: Pick<
|
||||
AgentManager,
|
||||
| "createAgent"
|
||||
| "getAgent"
|
||||
| "reloadAgentSession"
|
||||
| "resumeAgentFromPersistence"
|
||||
>;
|
||||
agentStorage: Pick<AgentSnapshotStore, "get">;
|
||||
logger: pino.Logger;
|
||||
};
|
||||
|
||||
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
|
||||
export class AgentLoadingService {
|
||||
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
|
||||
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
|
||||
private readonly logger: pino.Logger;
|
||||
|
||||
constructor(options: AgentLoadingServiceOptions) {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.logger = options.logger.child({ component: "agent-loading" });
|
||||
}
|
||||
|
||||
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = this.loadStoredAgent(options);
|
||||
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (current === initPromise) {
|
||||
pendingAgentBootstrapLoads.delete(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resumeAgent(options: {
|
||||
handle: AgentPersistenceHandle;
|
||||
overrides?: Partial<AgentSessionConfig>;
|
||||
}): Promise<ManagedAgent> {
|
||||
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
|
||||
}
|
||||
|
||||
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing.persistence
|
||||
? await this.agentManager.reloadAgentSession(options.agentId)
|
||||
: existing;
|
||||
}
|
||||
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
if (!handle) {
|
||||
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
|
||||
return this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent resumed from persistence",
|
||||
);
|
||||
} else {
|
||||
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
|
||||
labels: record.labels,
|
||||
});
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent created from stored config",
|
||||
);
|
||||
}
|
||||
|
||||
return this.agentManager.getAgent(options.agentId) ?? snapshot;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
@@ -178,7 +178,7 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
@@ -192,7 +192,7 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
config: { ...baseConfig, ...configOverrides },
|
||||
lifecycle,
|
||||
|
||||
@@ -62,6 +62,8 @@ export function toStoredAgentRecord(
|
||||
config: config ?? null,
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
terminalExit: agent.terminalExit ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
attentionReason: agent.attention.requiresAttention ? agent.attention.attentionReason : null,
|
||||
attentionTimestamp: agent.attention.requiresAttention
|
||||
@@ -86,6 +88,7 @@ export function toAgentPayload(
|
||||
id: agent.id,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
terminal: agent.terminal,
|
||||
model: agent.config.model ?? null,
|
||||
thinkingOptionId,
|
||||
effectiveThinkingOptionId,
|
||||
@@ -112,6 +115,10 @@ export function toAgentPayload(
|
||||
payload.lastError = agent.lastError;
|
||||
}
|
||||
|
||||
if (agent.terminalExit) {
|
||||
payload.terminalExit = agent.terminalExit;
|
||||
}
|
||||
|
||||
// Handle attention state
|
||||
payload.requiresAttention = agent.attention.requiresAttention;
|
||||
if (agent.attention.requiresAttention) {
|
||||
@@ -127,6 +134,9 @@ export function toAgentPayload(
|
||||
|
||||
function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentConfig | null {
|
||||
const serializable: SerializableAgentConfig = {};
|
||||
if (config.terminal !== undefined) {
|
||||
serializable.terminal = config.terminal;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "title")) {
|
||||
serializable.title = config.title ?? null;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export type AgentCapabilityFlags = {
|
||||
supportsMcpServers: boolean;
|
||||
supportsReasoningStream: boolean;
|
||||
supportsToolInvocations: boolean;
|
||||
supportsTerminalMode: boolean;
|
||||
};
|
||||
|
||||
export type AgentPersistenceHandle = {
|
||||
@@ -356,9 +357,16 @@ export type PersistedAgentDescriptor = {
|
||||
timeline: AgentTimelineItem[];
|
||||
};
|
||||
|
||||
export type TerminalCommand = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type AgentSessionConfig = {
|
||||
provider: AgentProvider;
|
||||
cwd: string;
|
||||
terminal?: boolean;
|
||||
/**
|
||||
* Provider-agnostic system/developer instruction string.
|
||||
* Mapped by each provider to its native instruction field.
|
||||
@@ -428,6 +436,12 @@ export interface AgentClient {
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
buildTerminalCreateCommand?(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand;
|
||||
buildTerminalResumeCommand?(handle: AgentPersistenceHandle): TerminalCommand;
|
||||
/**
|
||||
* Check if this provider is available (CLI binary is installed).
|
||||
* Returns true if available, false otherwise.
|
||||
|
||||
19
packages/server/src/server/agent/agent-snapshot-store.ts
Normal file
19
packages/server/src/server/agent/agent-snapshot-store.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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>;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { Logger } from "pino";
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
terminal: z.boolean().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
@@ -56,6 +58,16 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
})
|
||||
.optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
terminalExit: z
|
||||
.object({
|
||||
command: z.string(),
|
||||
message: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
signal: z.number().nullable(),
|
||||
outputLines: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
@@ -65,12 +77,22 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
|
||||
export type SerializableAgentConfig = Pick<
|
||||
AgentSessionConfig,
|
||||
"title" | "modeId" | "model" | "thinkingOptionId" | "extra" | "systemPrompt" | "mcpServers"
|
||||
| "terminal"
|
||||
| "title"
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "extra"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||
return STORED_AGENT_SCHEMA.parse(value);
|
||||
}
|
||||
|
||||
export class AgentStorage {
|
||||
export class AgentStorage implements AgentSnapshotStore {
|
||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||
private pathById: Map<string, string> = new Map();
|
||||
private pathsById: Map<string, Set<string>> = new Map();
|
||||
@@ -168,19 +190,22 @@ export class AgentStorage {
|
||||
|
||||
async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
workspaceIdOrOptions?: number | { title?: string | null; internal?: boolean },
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
const nextOptions =
|
||||
typeof workspaceIdOrOptions === "number" ? options : workspaceIdOrOptions;
|
||||
await this.load();
|
||||
await this.waitForPendingWrite(agent.id);
|
||||
const existing = (await this.get(agent.id)) ?? null;
|
||||
const hasTitleOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "title");
|
||||
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "title");
|
||||
const hasInternalOverride =
|
||||
options !== undefined && Object.prototype.hasOwnProperty.call(options, "internal");
|
||||
nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, "internal");
|
||||
const record = toStoredAgentRecord(agent, {
|
||||
title: hasTitleOverride ? (options?.title ?? null) : (existing?.title ?? null),
|
||||
title: hasTitleOverride ? (nextOptions?.title ?? null) : (existing?.title ?? null),
|
||||
createdAt: existing?.createdAt,
|
||||
internal: hasInternalOverride ? options?.internal : (agent.internal ?? existing?.internal),
|
||||
internal: hasInternalOverride ? nextOptions?.internal : (agent.internal ?? existing?.internal),
|
||||
});
|
||||
|
||||
// Preserve soft-delete/archive status across snapshot flushes.
|
||||
@@ -300,7 +325,7 @@ export class AgentStorage {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(content);
|
||||
return STORED_AGENT_SCHEMA.parse(parsed);
|
||||
return parseStoredAgentRecord(parsed);
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error, filePath }, "Skipping invalid agent record");
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
export type AgentTimelineRow = {
|
||||
seq: number;
|
||||
timestamp: string;
|
||||
item: AgentTimelineItem;
|
||||
};
|
||||
|
||||
export type AgentTimelineCursor = {
|
||||
seq: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export type AgentTimelineFetchOptions = {
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
* Number of canonical rows to return.
|
||||
* - undefined: store default
|
||||
* - 0: all rows in the selected window
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineWindow = {
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
nextSeq: number;
|
||||
};
|
||||
|
||||
export type AgentTimelineFetchResult = {
|
||||
direction: AgentTimelineFetchDirection;
|
||||
window: AgentTimelineWindow;
|
||||
hasOlder: boolean;
|
||||
hasNewer: boolean;
|
||||
rows: AgentTimelineRow[];
|
||||
};
|
||||
|
||||
export interface AgentTimelineStore {
|
||||
appendCommitted(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): Promise<AgentTimelineRow>;
|
||||
fetchCommitted(
|
||||
agentId: string,
|
||||
options?: AgentTimelineFetchOptions,
|
||||
): Promise<AgentTimelineFetchResult>;
|
||||
getLatestCommittedSeq(agentId: string): Promise<number>;
|
||||
getCommittedRows(agentId: string): Promise<AgentTimelineRow[]>;
|
||||
getLastItem(agentId: string): Promise<AgentTimelineItem | null>;
|
||||
getLastAssistantMessage(agentId: string): Promise<string | null>;
|
||||
hasCommittedUserMessage(
|
||||
agentId: string,
|
||||
options: { messageId: string; text: string },
|
||||
): Promise<boolean>;
|
||||
deleteAgent(agentId: string): Promise<void>;
|
||||
bulkInsert(agentId: string, rows: readonly AgentTimelineRow[]): Promise<void>;
|
||||
}
|
||||
244
packages/server/src/server/agent/agent-timeline-store.ts
Normal file
244
packages/server/src/server/agent/agent-timeline-store.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "./agent-timeline-store-types.js";
|
||||
|
||||
export type SeedAgentTimelineOptions = {
|
||||
items?: readonly AgentTimelineItem[];
|
||||
rows?: readonly AgentTimelineRow[];
|
||||
nextSeq?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
type AgentTimelineState = {
|
||||
rows: AgentTimelineRow[];
|
||||
nextSeq: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
|
||||
if (typeof messageId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export class InMemoryAgentTimelineStore {
|
||||
private readonly states = new Map<string, AgentTimelineState>();
|
||||
|
||||
has(agentId: string): boolean {
|
||||
return this.states.has(agentId);
|
||||
}
|
||||
|
||||
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
|
||||
const timestamp = options?.timestamp ?? new Date().toISOString();
|
||||
const rows = options?.rows?.length
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq =
|
||||
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
|
||||
this.states.set(agentId, {
|
||||
rows,
|
||||
nextSeq,
|
||||
});
|
||||
}
|
||||
|
||||
delete(agentId: string): void {
|
||||
this.states.delete(agentId);
|
||||
}
|
||||
|
||||
getItems(agentId: string): AgentTimelineItem[] {
|
||||
return this.requireState(agentId).rows.map((row) => row.item);
|
||||
}
|
||||
|
||||
getRows(agentId: string): AgentTimelineRow[] {
|
||||
return this.requireState(agentId).rows.map(cloneRow);
|
||||
}
|
||||
|
||||
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
requestedLimit === undefined
|
||||
? DEFAULT_TIMELINE_FETCH_LIMIT
|
||||
: Math.max(0, Math.floor(requestedLimit));
|
||||
const cursor = options?.cursor;
|
||||
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
|
||||
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
|
||||
const selectAll = limit === 0;
|
||||
|
||||
const window = {
|
||||
minSeq,
|
||||
maxSeq,
|
||||
nextSeq: state.nextSeq,
|
||||
};
|
||||
|
||||
if (state.rows.length === 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "after") {
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
append(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): AgentTimelineRow {
|
||||
const state = this.requireState(agentId);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: state.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item,
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
state.rows.push(row);
|
||||
return cloneRow(row);
|
||||
}
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.rows[state.rows.length - 1]?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const rows = this.requireState(agentId).rows;
|
||||
const chunks: string[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const item = rows[i]!.item;
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length > 0) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.reverse().join("");
|
||||
}
|
||||
|
||||
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
|
||||
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const messageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
if (!messageId) {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
});
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
|
||||
const messageId = normalizeTimelineMessageId(options.messageId);
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.requireState(agentId).rows.some((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
return rowMessageId === messageId && row.item.text === options.text;
|
||||
});
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
const state = this.states.get(agentId);
|
||||
if (!state) {
|
||||
throw new Error(`Unknown agent '${agentId}'`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private buildRowsFromItems(
|
||||
items: readonly AgentTimelineItem[],
|
||||
startSeq: number,
|
||||
timestamp: string,
|
||||
): AgentTimelineRow[] {
|
||||
let nextSeq = startSeq;
|
||||
return items.map((item) => {
|
||||
const row: AgentTimelineRow = {
|
||||
seq: nextSeq,
|
||||
timestamp,
|
||||
item,
|
||||
};
|
||||
nextSeq += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { tmpdir } from "node:os";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
|
||||
type TestDeps = {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
spies: {
|
||||
agentManager: Record<string, any>;
|
||||
agentStorage: Record<string, any>;
|
||||
@@ -41,7 +41,7 @@ function createTestDeps(): TestDeps {
|
||||
|
||||
return {
|
||||
agentManager: agentManagerSpies as unknown as AgentManager,
|
||||
agentStorage: agentStorageSpies as unknown as AgentStorage,
|
||||
agentStorage: agentStorageSpies as unknown as AgentSnapshotStore,
|
||||
spies: {
|
||||
agentManager: agentManagerSpies,
|
||||
agentStorage: agentStorageSpies,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -31,7 +31,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
@@ -240,7 +240,7 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
@@ -254,7 +254,7 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
|
||||
@@ -180,6 +180,14 @@ export function applyProviderEnv(
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function sanitizeTerminalEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an executable name to its absolute path the way the user's shell would.
|
||||
*
|
||||
|
||||
@@ -122,6 +122,27 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
defaultModel: "gpt-5.1-codex-mini",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini CLI",
|
||||
description: "Google's terminal-based coding agent",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "amp",
|
||||
label: "AMP",
|
||||
description: "Sourcegraph's terminal-based coding agent",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "aider",
|
||||
label: "Aider",
|
||||
description: "Paul Gauthier's terminal-based coding assistant",
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: "OpenCode",
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { Logger } from "pino";
|
||||
|
||||
import { ClaudeAgentClient } from "./providers/claude-agent.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { GeminiAgentClient } from "./providers/gemini-agent.js";
|
||||
import { AmpAgentClient } from "./providers/amp-agent.js";
|
||||
import { AiderAgentClient } from "./providers/aider-agent.js";
|
||||
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
|
||||
|
||||
import {
|
||||
@@ -40,6 +43,9 @@ export function buildProviderRegistry(
|
||||
runtimeSettings: runtimeSettings?.claude,
|
||||
});
|
||||
const codexClient = new CodexAppServerAgentClient(logger, runtimeSettings?.codex);
|
||||
const geminiClient = new GeminiAgentClient(runtimeSettings?.gemini);
|
||||
const ampClient = new AmpAgentClient(runtimeSettings?.amp);
|
||||
const aiderClient = new AiderAgentClient(runtimeSettings?.aider);
|
||||
const opencodeClient = new OpenCodeAgentClient(logger, runtimeSettings?.opencode);
|
||||
|
||||
return {
|
||||
@@ -55,6 +61,21 @@ export function buildProviderRegistry(
|
||||
new CodexAppServerAgentClient(logger, runtimeSettings?.codex),
|
||||
fetchModels: (options) => codexClient.listModels(options),
|
||||
},
|
||||
gemini: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "gemini")!,
|
||||
createClient: () => new GeminiAgentClient(runtimeSettings?.gemini),
|
||||
fetchModels: (options) => geminiClient.listModels(options),
|
||||
},
|
||||
amp: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "amp")!,
|
||||
createClient: () => new AmpAgentClient(runtimeSettings?.amp),
|
||||
fetchModels: (options) => ampClient.listModels(options),
|
||||
},
|
||||
aider: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "aider")!,
|
||||
createClient: () => new AiderAgentClient(runtimeSettings?.aider),
|
||||
fetchModels: (options) => aiderClient.listModels(options),
|
||||
},
|
||||
opencode: {
|
||||
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "opencode")!,
|
||||
createClient: (logger: Logger) => new OpenCodeAgentClient(logger, runtimeSettings?.opencode),
|
||||
@@ -74,6 +95,9 @@ export function createAllClients(
|
||||
return {
|
||||
claude: registry.claude.createClient(logger),
|
||||
codex: registry.codex.createClient(logger),
|
||||
gemini: registry.gemini.createClient(logger),
|
||||
amp: registry.amp.createClient(logger),
|
||||
aider: registry.aider.createClient(logger),
|
||||
opencode: registry.opencode.createClient(logger),
|
||||
};
|
||||
}
|
||||
|
||||
110
packages/server/src/server/agent/providers/aider-agent.ts
Normal file
110
packages/server/src/server/agent/providers/aider-agent.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
const AIDER_PROVIDER = "aider" as const;
|
||||
|
||||
const AIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type AiderAgentConfig = AgentSessionConfig & { provider: "aider" };
|
||||
|
||||
function resolveAiderBinary(): string {
|
||||
const found = findExecutable("aider");
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"Aider binary not found. Install Aider and ensure 'aider' is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("Aider currently supports terminal mode only in Paseo.");
|
||||
}
|
||||
|
||||
export class AiderAgentClient implements AgentClient {
|
||||
readonly provider = AIDER_PROVIDER;
|
||||
readonly capabilities = AIDER_CAPABILITIES;
|
||||
|
||||
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
|
||||
|
||||
async createSession(
|
||||
_config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
_overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
_initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveAiderBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
// Aider uses positional arguments for file paths, not interactive prompts.
|
||||
args: [...launchPrefix.args, "--no-auto-commits"],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAiderBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): AiderAgentConfig {
|
||||
if (config.provider !== AIDER_PROVIDER) {
|
||||
throw new Error(`AiderAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: AIDER_PROVIDER };
|
||||
}
|
||||
}
|
||||
109
packages/server/src/server/agent/providers/amp-agent.ts
Normal file
109
packages/server/src/server/agent/providers/amp-agent.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
const AMP_PROVIDER = "amp" as const;
|
||||
|
||||
const AMP_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type AmpAgentConfig = AgentSessionConfig & { provider: "amp" };
|
||||
|
||||
function resolveAmpBinary(): string {
|
||||
const found = findExecutable("amp");
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"AMP binary not found. Install AMP and ensure 'amp' is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("AMP currently supports terminal mode only in Paseo.");
|
||||
}
|
||||
|
||||
export class AmpAgentClient implements AgentClient {
|
||||
readonly provider = AMP_PROVIDER;
|
||||
readonly capabilities = AMP_CAPABILITIES;
|
||||
|
||||
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
|
||||
|
||||
async createSession(
|
||||
_config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
_overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
_initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveAmpBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveAmpBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): AmpAgentConfig {
|
||||
if (config.provider !== AMP_PROVIDER) {
|
||||
throw new Error(`AmpAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: AMP_PROVIDER };
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
@@ -357,4 +357,52 @@ describe("ClaudeAgentClient.listModels", () => {
|
||||
]);
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("keeps the Claude control-plane query open until supportedModels resolves", async () => {
|
||||
const queryMock = createSupportedModelsQueryMock([
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[]);
|
||||
let promptIterator: AsyncIterator<SDKUserMessage, void> | null = null;
|
||||
let promptNextPromise: Promise<IteratorResult<SDKUserMessage, void>> | null = null;
|
||||
let promptClosedBeforeModelsResolved = false;
|
||||
|
||||
queryMock.supportedModels = vi.fn(async () => {
|
||||
promptNextPromise = promptIterator?.next() ?? null;
|
||||
if (!promptNextPromise) {
|
||||
throw new Error("Prompt iterator not captured");
|
||||
}
|
||||
promptNextPromise.then(() => {
|
||||
promptClosedBeforeModelsResolved = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(promptClosedBeforeModelsResolved).toBe(false);
|
||||
return [
|
||||
{
|
||||
value: "default",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
},
|
||||
] satisfies ModelInfo[];
|
||||
});
|
||||
|
||||
const queryFactory = vi.fn(({ prompt }) => {
|
||||
promptIterator = prompt[Symbol.asyncIterator]();
|
||||
return queryMock;
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: queryFactory as never,
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd: process.cwd() });
|
||||
|
||||
expect(models).toHaveLength(1);
|
||||
expect(promptNextPromise).not.toBeNull();
|
||||
await expect(promptNextPromise).resolves.toEqual({ done: true, value: undefined });
|
||||
expect(queryMock.return).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,10 +66,12 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
@@ -102,6 +104,7 @@ const CLAUDE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
@@ -233,10 +236,6 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyClaudePrompt(): AsyncGenerator<SDKUserMessage, void, undefined> {
|
||||
return (async function* empty() {})();
|
||||
}
|
||||
|
||||
function isClaudeThinkingEffort(value: string | null | undefined): value is ClaudeThinkingEffort {
|
||||
return value === "low" || value === "medium" || value === "high" || value === "max";
|
||||
}
|
||||
@@ -1045,8 +1044,9 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const claudeQuery = this.queryFactory({
|
||||
prompt: createEmptyClaudePrompt(),
|
||||
prompt: input.iterable,
|
||||
options: applyRuntimeSettingsToClaudeOptions(
|
||||
{
|
||||
cwd: options?.cwd ?? process.cwd(),
|
||||
@@ -1065,13 +1065,13 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.logger.warn({ err: error }, "Failed to query Claude supportedModels()");
|
||||
throw error;
|
||||
} finally {
|
||||
input.end();
|
||||
try {
|
||||
await claudeQuery.return?.();
|
||||
} catch {
|
||||
// ignore control-plane shutdown errors
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async listPersistedAgents(
|
||||
@@ -1099,6 +1099,73 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return descriptors;
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
const baseCommand = findExecutable("claude") ?? "claude";
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const spawnCommand = resolveClaudeSpawnCommand(
|
||||
{
|
||||
command: baseCommand,
|
||||
args: [],
|
||||
cwd: claudeConfig.cwd,
|
||||
env: terminalEnv,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
);
|
||||
const args = [...spawnCommand.args, "--session-id", handle.sessionId];
|
||||
if (claudeConfig.modeId === "bypassPermissions") {
|
||||
args.push("--dangerously-skip-permissions");
|
||||
} else if (claudeConfig.modeId) {
|
||||
args.push("--permission-mode", claudeConfig.modeId);
|
||||
}
|
||||
if (claudeConfig.model) {
|
||||
args.push("--model", claudeConfig.model);
|
||||
}
|
||||
if (claudeConfig.thinkingOptionId && claudeConfig.thinkingOptionId !== "default") {
|
||||
args.push("--effort", claudeConfig.thinkingOptionId);
|
||||
}
|
||||
if (claudeConfig.systemPrompt?.trim()) {
|
||||
args.push("--append-system-prompt", claudeConfig.systemPrompt.trim());
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: spawnCommand.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const baseCommand = findExecutable("claude") ?? "claude";
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const spawnCommand = resolveClaudeSpawnCommand(
|
||||
{
|
||||
command: baseCommand,
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: terminalEnv,
|
||||
signal: new AbortController().signal,
|
||||
},
|
||||
this.runtimeSettings,
|
||||
);
|
||||
return {
|
||||
command: spawnCommand.command,
|
||||
args: [...spawnCommand.args, "--resume", handle.sessionId],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command = this.runtimeSettings?.command;
|
||||
if (command?.mode === "replace") {
|
||||
|
||||
@@ -19,9 +19,11 @@ import type {
|
||||
AgentTimelineItem,
|
||||
ToolCallTimelineItem,
|
||||
AgentUsage,
|
||||
AgentPersistenceHandle,
|
||||
ListModelsOptions,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
@@ -59,6 +62,7 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const CODEX_MODES: AgentMode[] = [
|
||||
@@ -3532,6 +3536,59 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const modeId = sessionConfig.modeId ?? DEFAULT_CODEX_MODE_ID;
|
||||
validateCodexMode(modeId);
|
||||
const preset = MODE_PRESETS[modeId] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
||||
const approvalPolicy = sessionConfig.approvalPolicy ?? preset.approvalPolicy;
|
||||
const sandbox = sessionConfig.sandboxMode ?? preset.sandbox;
|
||||
const args = [...launchPrefix.args, "-c", `sessionId=\"${handle.sessionId}\"`];
|
||||
if (sessionConfig.model) {
|
||||
args.push("--model", sessionConfig.model);
|
||||
}
|
||||
args.push("--ask-for-approval", approvalPolicy, "--sandbox", sandbox);
|
||||
if (
|
||||
typeof sessionConfig.networkAccess === "boolean"
|
||||
? sessionConfig.networkAccess
|
||||
: preset.networkAccess === true
|
||||
) {
|
||||
args.push("--search");
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [
|
||||
...launchPrefix.args,
|
||||
"resume",
|
||||
handle.nativeHandle ?? handle.sessionId,
|
||||
],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
const child = this.spawnAppServer();
|
||||
const client = new CodexAppServerClient(child, this.logger);
|
||||
|
||||
128
packages/server/src/server/agent/providers/gemini-agent.ts
Normal file
128
packages/server/src/server/agent/providers/gemini-agent.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentModelDefinition,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
ListModelsOptions,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
isProviderCommandAvailable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
|
||||
const GEMINI_PROVIDER = "gemini" as const;
|
||||
|
||||
const GEMINI_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
type GeminiAgentConfig = AgentSessionConfig & { provider: "gemini" };
|
||||
|
||||
function resolveGeminiBinary(): string {
|
||||
const found = findExecutable("gemini");
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
throw new Error(
|
||||
"Gemini CLI binary not found. Install Gemini CLI and ensure 'gemini' is available in your shell PATH.",
|
||||
);
|
||||
}
|
||||
|
||||
function createUnsupportedSessionError(): Error {
|
||||
return new Error("Gemini CLI currently supports terminal mode only in Paseo.");
|
||||
}
|
||||
|
||||
export class GeminiAgentClient implements AgentClient {
|
||||
readonly provider = GEMINI_PROVIDER;
|
||||
readonly capabilities = GEMINI_CAPABILITIES;
|
||||
|
||||
constructor(private readonly runtimeSettings?: ProviderRuntimeSettings) {}
|
||||
|
||||
async createSession(
|
||||
_config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
_overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
throw createUnsupportedSessionError();
|
||||
}
|
||||
|
||||
async listModels(_options?: ListModelsOptions): Promise<AgentModelDefinition[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
_handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
this.assertConfig(config);
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveGeminiBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const args = [...launchPrefix.args];
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push("-i", initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(_handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveGeminiBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args, "--resume"],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.runtimeSettings?.command?.mode === "replace") {
|
||||
return existsSync(this.runtimeSettings.command.argv[0]);
|
||||
}
|
||||
return isProviderCommandAvailable(this.runtimeSettings?.command, resolveGeminiBinary);
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): GeminiAgentConfig {
|
||||
if (config.provider !== GEMINI_PROVIDER) {
|
||||
throw new Error(`GeminiAgentClient received config for provider '${config.provider}'`);
|
||||
}
|
||||
return { ...config, provider: GEMINI_PROVIDER };
|
||||
}
|
||||
}
|
||||
@@ -28,11 +28,13 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
McpServerConfig,
|
||||
PersistedAgentDescriptor,
|
||||
TerminalCommand,
|
||||
} from "../agent-sdk-types.js";
|
||||
import {
|
||||
applyProviderEnv,
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
sanitizeTerminalEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
@@ -44,6 +46,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: true,
|
||||
};
|
||||
|
||||
const DEFAULT_MODES: AgentMode[] = [
|
||||
@@ -559,6 +562,53 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
return [];
|
||||
}
|
||||
|
||||
buildTerminalCreateCommand(
|
||||
config: AgentSessionConfig,
|
||||
handle: AgentPersistenceHandle,
|
||||
initialPrompt?: string,
|
||||
): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
const args = [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId];
|
||||
if (config.cwd) {
|
||||
args.push(config.cwd);
|
||||
}
|
||||
if (config.model) {
|
||||
args.push("--model", config.model);
|
||||
}
|
||||
if (config.modeId) {
|
||||
args.push("--agent", config.modeId);
|
||||
}
|
||||
if (initialPrompt?.trim()) {
|
||||
args.push(initialPrompt.trim());
|
||||
}
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args,
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
buildTerminalResumeCommand(handle: AgentPersistenceHandle): TerminalCommand {
|
||||
const launchPrefix = resolveProviderCommandPrefix(
|
||||
this.runtimeSettings?.command,
|
||||
resolveOpenCodeBinary,
|
||||
);
|
||||
const terminalEnv = sanitizeTerminalEnv(
|
||||
applyProviderEnv(process.env as Record<string, string | undefined>, this.runtimeSettings),
|
||||
);
|
||||
return {
|
||||
command: launchPrefix.command,
|
||||
args: [...launchPrefix.args, "--session", handle.nativeHandle ?? handle.sessionId],
|
||||
env: terminalEnv,
|
||||
};
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const command = this.runtimeSettings?.command;
|
||||
if (command?.mode === "replace") {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { AiderAgentClient } from "./aider-agent.js";
|
||||
import { AmpAgentClient } from "./amp-agent.js";
|
||||
import { GeminiAgentClient } from "./gemini-agent.js";
|
||||
|
||||
function createExecutable(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "terminal-provider-test-"));
|
||||
const file = path.join(dir, "provider-bin");
|
||||
writeFileSync(file, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(file, 0o755);
|
||||
return file;
|
||||
}
|
||||
|
||||
const buildConfig = (provider: "gemini" | "amp" | "aider"): AgentSessionConfig => ({
|
||||
provider,
|
||||
cwd: "/tmp/worktree",
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
describe("terminal-only providers", () => {
|
||||
test("Gemini builds an interactive prompt command without injecting cwd flags", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new GeminiAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(
|
||||
buildConfig("gemini"),
|
||||
{ provider: "gemini", sessionId: "session-1" },
|
||||
"Fix the bug",
|
||||
);
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual(["-i", "Fix the bug"]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("AMP launches without unsupported cwd flags", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new AmpAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(buildConfig("amp"), {
|
||||
provider: "amp",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual([]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Aider does not treat initial prompts as positional CLI arguments", () => {
|
||||
const executable = createExecutable();
|
||||
try {
|
||||
const client = new AiderAgentClient({
|
||||
command: { mode: "replace", argv: [executable] },
|
||||
});
|
||||
|
||||
const command = client.buildTerminalCreateCommand(
|
||||
buildConfig("aider"),
|
||||
{ provider: "aider", sessionId: "session-1" },
|
||||
"Refactor the parser",
|
||||
);
|
||||
|
||||
expect(command.command).toBe(executable);
|
||||
expect(command.args).toEqual(["--no-auto-commits"]);
|
||||
} finally {
|
||||
rmSync(path.dirname(executable), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("provider availability respects missing replacement binaries", async () => {
|
||||
const missingPath = path.join(os.tmpdir(), "missing-terminal-provider");
|
||||
|
||||
await expect(
|
||||
new GeminiAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
new AmpAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
new AiderAgentClient({
|
||||
command: { mode: "replace", argv: [missingPath] },
|
||||
}).isAvailable(),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
@@ -8,6 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { createPaseoDaemon, parseListenString, type PaseoDaemonConfig } from "./bootstrap.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import { openPaseoDatabase } from "./db/sqlite-database.js";
|
||||
import { agentSnapshots, projects, workspaces } from "./db/schema.js";
|
||||
|
||||
describe("paseo daemon bootstrap", () => {
|
||||
afterEach(() => {
|
||||
@@ -199,4 +202,395 @@ describe("paseo daemon bootstrap", () => {
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("imports legacy project and workspace JSON into the DB on first bootstrap", async () => {
|
||||
const { config, cleanup } = await createBootstrapConfig();
|
||||
writeLegacyProjectWorkspaceJson(config.paseoHome, {
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/project-1",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const daemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
|
||||
try {
|
||||
await daemon.start();
|
||||
await daemon.stop();
|
||||
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: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00: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: "/tmp/project-1",
|
||||
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 cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not duplicate imported legacy JSON across daemon restarts", async () => {
|
||||
const { config, cleanup } = await createBootstrapConfig();
|
||||
writeLegacyProjectWorkspaceJson(config.paseoHome, {
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/project-1",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
const firstDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
await firstDaemon.start();
|
||||
await firstDaemon.stop();
|
||||
|
||||
const secondDaemon = await createPaseoDaemon(config, pino({ level: "silent" }));
|
||||
await secondDaemon.start();
|
||||
await secondDaemon.stop();
|
||||
|
||||
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 cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("imports legacy project, workspace, and agent JSON into one SQLite bootstrap without duplicating records", async () => {
|
||||
const { config, cleanup } = await createBootstrapConfig();
|
||||
writeLegacyProjectWorkspaceJson(config.paseoHome, {
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/project-1",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
writeLegacyAgentJson(config.paseoHome, "agents/agent-1.json", {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project-1",
|
||||
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: "/tmp/project-1",
|
||||
workspaceId: workspaceRows[0]!.id,
|
||||
title: "Imported Agent",
|
||||
requiresAttention: false,
|
||||
internal: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("imports large legacy agent JSON batches during SQLite bootstrap", async () => {
|
||||
const { config, cleanup } = await createBootstrapConfig();
|
||||
writeLegacyProjectWorkspaceJson(config.paseoHome, {
|
||||
projects: [
|
||||
{
|
||||
projectId: "project-1",
|
||||
rootPath: "/tmp/project-1",
|
||||
kind: "git",
|
||||
displayName: "Project One",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
workspaces: [
|
||||
{
|
||||
workspaceId: "workspace-1",
|
||||
projectId: "project-1",
|
||||
cwd: "/tmp/project-1",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for (let index = 0; index < 150; index += 1) {
|
||||
writeLegacyAgentJson(config.paseoHome, `agents/project-1/agent-${index}.json`, {
|
||||
id: `agent-${index}`,
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project-1",
|
||||
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 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");
|
||||
}
|
||||
|
||||
@@ -93,12 +93,17 @@ import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
import { createSpeechService } from "./speech/speech-runtime.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
|
||||
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
|
||||
import { DbProjectRegistry } from "./db/db-project-registry.js";
|
||||
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
|
||||
import { 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 { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { LoopService } from "./loop-service.js";
|
||||
@@ -187,7 +192,7 @@ export type PaseoDaemonConfig = {
|
||||
export interface PaseoDaemon {
|
||||
config: PaseoDaemonConfig;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager: TerminalManager;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -202,6 +207,7 @@ export async function createPaseoDaemon(
|
||||
const bootstrapStart = performance.now();
|
||||
const elapsed = () => `${(performance.now() - bootstrapStart).toFixed(0)}ms`;
|
||||
const daemonVersion = resolveDaemonVersion(import.meta.url);
|
||||
let database: PaseoDatabaseHandle | null = null;
|
||||
|
||||
try {
|
||||
const serverId = getOrCreateServerId(config.paseoHome, { logger });
|
||||
@@ -352,20 +358,33 @@ export async function createPaseoDaemon(
|
||||
|
||||
const httpServer = createHTTPServer(app);
|
||||
|
||||
const agentStorage = new AgentStorage(config.agentStoragePath, logger);
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(config.paseoHome, "projects", "projects.json"),
|
||||
logger,
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(config.paseoHome, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
database = await openPaseoDatabase(path.join(config.paseoHome, "db"));
|
||||
logger.info({ elapsed: elapsed() }, "Paseo database opened");
|
||||
|
||||
const agentStorage = new DbAgentSnapshotStore(database.db);
|
||||
const chatService = new FileBackedChatService({
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
});
|
||||
const agentManager = new AgentManager({
|
||||
const durableTimelineStore = new DbAgentTimelineStore(database.db);
|
||||
let agentManager: AgentManager | null = null;
|
||||
const terminalManager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: (terminalId) => agentManager?.getAgentIdForTerminal(terminalId) ?? null,
|
||||
onAgentBoundTerminalTitleChange: async ({ agentId, title }) => {
|
||||
if (!agentManager) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await agentManager.setTitle(agentId, title);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, agentId },
|
||||
"Failed to propagate bound terminal title to agent state",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
agentManager = new AgentManager({
|
||||
clients: {
|
||||
...createAllClients(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
@@ -373,26 +392,34 @@ export async function createPaseoDaemon(
|
||||
...config.agentClients,
|
||||
},
|
||||
registry: agentStorage,
|
||||
durableTimelineStore,
|
||||
terminalManager,
|
||||
logger,
|
||||
});
|
||||
|
||||
const terminalManager = createTerminalManager();
|
||||
const projectRegistry = new DbProjectRegistry(database.db);
|
||||
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
|
||||
|
||||
const detachAgentStoragePersistence = attachAgentStoragePersistence(
|
||||
logger,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
);
|
||||
await agentStorage.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Agent storage initialized");
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome: config.paseoHome,
|
||||
agentStorage,
|
||||
const reconciliationService = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Workspace registries bootstrapped");
|
||||
reconciliationService.start();
|
||||
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
|
||||
|
||||
await importLegacyProjectWorkspaceJson({
|
||||
db: database.db,
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Legacy project/workspace import checked");
|
||||
await importLegacyAgentSnapshots({
|
||||
db: database.db,
|
||||
paseoHome: config.paseoHome,
|
||||
logger,
|
||||
});
|
||||
logger.info({ elapsed: elapsed() }, "Legacy agent snapshot import checked");
|
||||
await chatService.initialize();
|
||||
logger.info({ elapsed: elapsed() }, "Chat service initialized");
|
||||
const checkoutDiffManager = new CheckoutDiffManager({
|
||||
@@ -732,10 +759,9 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
reconciliationService.stop();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
detachAgentStoragePersistence();
|
||||
await agentStorage.flush().catch(() => undefined);
|
||||
await shutdownProviders(logger, {
|
||||
runtimeSettings: config.agentProviderSettings,
|
||||
});
|
||||
@@ -749,6 +775,7 @@ export async function createPaseoDaemon(
|
||||
if (voiceMcpBridgeManager) {
|
||||
await voiceMcpBridgeManager.stop().catch(() => undefined);
|
||||
}
|
||||
await database?.close().catch(() => undefined);
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
@@ -768,6 +795,7 @@ export async function createPaseoDaemon(
|
||||
getListenTarget: () => boundListenTarget,
|
||||
};
|
||||
} catch (err) {
|
||||
await database?.close().catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,8 @@ describe("daemon client E2E", () => {
|
||||
expect(archivedResult).not.toBeNull();
|
||||
expect(archivedResult?.agent.archivedAt).toBeTruthy();
|
||||
expect(archivedResult?.agent.status).not.toBe("running");
|
||||
expect(archivedResult?.agent.requiresAttention).toBe(false);
|
||||
expect(archivedResult?.agent.attentionReason).toBeNull();
|
||||
expect(archivedResult?.project).not.toBeNull();
|
||||
expect(archivedResult?.project?.checkout.cwd).toBe(cwd);
|
||||
|
||||
@@ -309,6 +311,42 @@ describe("daemon client E2E", () => {
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("update_agent persists unloaded title and labels across auto-unarchive", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const created = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd,
|
||||
},
|
||||
});
|
||||
|
||||
await ctx.client.archiveAgent(created.id);
|
||||
await ctx.client.updateAgent(created.id, {
|
||||
name: "Pinned Title",
|
||||
labels: { lane: "phase-1a" },
|
||||
});
|
||||
|
||||
const archived = await ctx.client.fetchAgent(created.id);
|
||||
expect(archived).not.toBeNull();
|
||||
expect(archived?.agent.archivedAt).toBeTruthy();
|
||||
expect(archived?.agent.title).toBe("Pinned Title");
|
||||
expect(archived?.agent.labels).toMatchObject({ lane: "phase-1a" });
|
||||
|
||||
await ctx.client.sendMessage(created.id, "Say hello and nothing else");
|
||||
const finalState = await ctx.client.waitForFinish(created.id, 120000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
|
||||
const unarchived = await ctx.client.fetchAgent(created.id);
|
||||
expect(unarchived).not.toBeNull();
|
||||
expect(unarchived?.agent.archivedAt).toBeNull();
|
||||
expect(unarchived?.agent.title).toBe("Pinned Title");
|
||||
expect(unarchived?.agent.labels).toMatchObject({ lane: "phase-1a" });
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("returns home-scoped directory suggestions", async () => {
|
||||
const insideHomeDir = mkdtempSync(path.join(homedir(), "paseo-dir-suggestion-"));
|
||||
const outsideHomeDir = mkdtempSync(path.join(tmpdir(), "paseo-dir-suggestion-outside-"));
|
||||
@@ -529,7 +567,6 @@ describe("daemon client E2E", () => {
|
||||
const timelineResult = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
projection: "projected",
|
||||
});
|
||||
expect(timelineResult.agentId).toBe(agent.id);
|
||||
|
||||
@@ -756,7 +793,6 @@ describe("daemon client E2E", () => {
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
expect(timeline.entries.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -71,7 +71,6 @@ describe("daemon E2E", () => {
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "projected",
|
||||
});
|
||||
const refreshedResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
@@ -89,7 +88,6 @@ describe("daemon E2E", () => {
|
||||
await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "projected",
|
||||
});
|
||||
const clearResult = await ctx.client.fetchAgent(agent.id);
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
|
||||
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const idleAssistantText = timelineAtIdle.entries
|
||||
.filter(
|
||||
@@ -91,7 +90,6 @@ describe("daemon E2E (real claude) - autonomous wake simple", () => {
|
||||
const finalTimeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const finalAssistantText = finalTimeline.entries
|
||||
.filter(
|
||||
|
||||
@@ -390,7 +390,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAtIdle = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
await client.waitForAgentUpsert(
|
||||
@@ -405,7 +404,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
|
||||
timelineAtIdle.entries.length,
|
||||
@@ -417,7 +415,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const nextTimeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
sawTimelineGrowth = nextTimeline.entries.length > timelineAtIdle.entries.length;
|
||||
}
|
||||
@@ -605,7 +602,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineBeforeWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const summarized = timelineBeforeWake.entries.map(summarizeTimelineEntry);
|
||||
// Required by reproduction request: log timeline at idle edge before autonomous wake.
|
||||
@@ -735,7 +731,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -751,7 +746,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
|
||||
timelineAtWake.entries.length,
|
||||
@@ -916,7 +910,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAtWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -932,7 +925,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timelineAfterWake = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
expect(timelineAfterWake.entries.length).toBeGreaterThanOrEqual(
|
||||
timelineAtWake.entries.length,
|
||||
@@ -1066,7 +1058,6 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () =
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
assistantTexts = timeline.entries
|
||||
.filter(
|
||||
|
||||
@@ -114,11 +114,14 @@ describe("daemon E2E - persistence", () => {
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const timelineItems = timeline.entries.map((entry) => entry.item);
|
||||
expect(timelineItems.length).toBeGreaterThan(0);
|
||||
expect(timelineItems.some((item) => item.type === "assistant_message")).toBe(true);
|
||||
const assistantMessages = timelineItems.filter(
|
||||
(item): item is Extract<(typeof timelineItems)[number], { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message",
|
||||
);
|
||||
expect(assistantMessages).toEqual([{ type: "assistant_message", text: "timeline test" }]);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
cleaned = true;
|
||||
|
||||
@@ -48,7 +48,6 @@ describe("daemon E2E (real claude) - rewind user message dedupe", () => {
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
const rewindUserMessages = timeline.entries.filter(
|
||||
|
||||
@@ -462,6 +462,41 @@ describe("daemon E2E terminal", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("propagates debounced terminal titles through list responses and snapshots", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
const terminalId = created.terminal!.id;
|
||||
|
||||
ctx.client.sendTerminalInput(terminalId, {
|
||||
type: "input",
|
||||
data: "printf '\\033]0;Build Output\\007'\r",
|
||||
});
|
||||
|
||||
let listedTitle: string | undefined;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 10000) {
|
||||
const list = await ctx.client.listTerminals(cwd);
|
||||
listedTitle = list.terminals.find((terminal) => terminal.id === terminalId)?.title;
|
||||
if (listedTitle === "Build Output") {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
expect(listedTitle).toBe("Build Output");
|
||||
|
||||
const snapshotPromise = waitForTerminalSnapshot(
|
||||
ctx.client,
|
||||
terminalId,
|
||||
(state) => state.title === "Build Output",
|
||||
);
|
||||
await ctx.client.subscribeTerminal(terminalId);
|
||||
const snapshot = await snapshotPromise;
|
||||
|
||||
expect(snapshot.title).toBe("Build Output");
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30000);
|
||||
|
||||
test("subscribe response is sent before the initial snapshot frame", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const created = await ctx.client.createTerminal(cwd);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { createDaemonTestContext, type DaemonTestContext, DaemonClient } from "../test-utils/index.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
|
||||
}
|
||||
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
intervalMs = 10,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
function isSeqLessAssistantTimeline(
|
||||
message: SessionOutboundMessage,
|
||||
agentId: string,
|
||||
text?: string,
|
||||
): boolean {
|
||||
return (
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "assistant_message" &&
|
||||
message.payload.seq === undefined &&
|
||||
(text === undefined || message.payload.event.item.text === text)
|
||||
);
|
||||
}
|
||||
|
||||
describe("daemon E2E - timeline reconnect contract", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test("reconnect catches up committed rows without replaying a provisional seed", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const primaryCollector = createMessageCollector(ctx.client);
|
||||
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Reconnect Contract Test",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
for (let seq = 1; seq <= 120; seq += 1) {
|
||||
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: `committed row ${seq}`,
|
||||
});
|
||||
}
|
||||
|
||||
primaryCollector.clear();
|
||||
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "partial before disconnect",
|
||||
});
|
||||
await waitFor(() =>
|
||||
primaryCollector.messages.some((message) =>
|
||||
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
|
||||
),
|
||||
);
|
||||
|
||||
await ctx.client.close();
|
||||
|
||||
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "finalized while disconnected",
|
||||
});
|
||||
|
||||
const reconnectClient = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
});
|
||||
await reconnectClient.connect();
|
||||
const reconnectCollector = createMessageCollector(reconnectClient);
|
||||
|
||||
try {
|
||||
await reconnectClient.fetchAgents({
|
||||
subscribe: { subscriptionId: "timeline-reconnect-a" },
|
||||
});
|
||||
|
||||
expect(
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isSeqLessAssistantTimeline(message, agent.id),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: { seq: 120 },
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
expect(catchUp.entries).toHaveLength(1);
|
||||
expect(catchUp.entries[0]?.seq).toBe(121);
|
||||
expect(catchUp.entries[0]?.item).toEqual({
|
||||
type: "assistant_message",
|
||||
text: "finalized while disconnected",
|
||||
});
|
||||
} finally {
|
||||
reconnectCollector.unsubscribe();
|
||||
await reconnectClient.close();
|
||||
}
|
||||
} finally {
|
||||
primaryCollector.unsubscribe();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("reconnect with no new committed rows resumes from future live provisional updates only", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const primaryCollector = createMessageCollector(ctx.client);
|
||||
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd,
|
||||
title: "Reconnect No Seed Test",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
for (let seq = 1; seq <= 120; seq += 1) {
|
||||
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: `committed row ${seq}`,
|
||||
});
|
||||
}
|
||||
|
||||
primaryCollector.clear();
|
||||
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "partial before disconnect",
|
||||
});
|
||||
await waitFor(() =>
|
||||
primaryCollector.messages.some((message) =>
|
||||
isSeqLessAssistantTimeline(message, agent.id, "partial before disconnect"),
|
||||
),
|
||||
);
|
||||
|
||||
await ctx.client.close();
|
||||
|
||||
const reconnectClient = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
});
|
||||
await reconnectClient.connect();
|
||||
const reconnectCollector = createMessageCollector(reconnectClient);
|
||||
|
||||
try {
|
||||
await reconnectClient.fetchAgents({
|
||||
subscribe: { subscriptionId: "timeline-reconnect-b" },
|
||||
});
|
||||
|
||||
expect(
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isSeqLessAssistantTimeline(message, agent.id),
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const catchUp = await reconnectClient.fetchAgentTimeline(agent.id, {
|
||||
direction: "after",
|
||||
cursor: { seq: 120 },
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
expect(catchUp.entries).toHaveLength(0);
|
||||
|
||||
reconnectCollector.clear();
|
||||
await ctx.daemon.daemon.agentManager.emitLiveTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "fresh live after reconnect",
|
||||
});
|
||||
await waitFor(() =>
|
||||
reconnectCollector.messages.some((message) =>
|
||||
isSeqLessAssistantTimeline(message, agent.id, "fresh live after reconnect"),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
reconnectCollector.unsubscribe();
|
||||
await reconnectClient.close();
|
||||
}
|
||||
} finally {
|
||||
primaryCollector.unsubscribe();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ describe("daemon E2E - timeline window", () => {
|
||||
await ctx.cleanup();
|
||||
}, 60_000);
|
||||
|
||||
test("canonical tail limit keeps assistant chunks intact at the window boundary", async () => {
|
||||
test("canonical tail limit returns one finalized committed assistant row at the window boundary", async () => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
@@ -38,16 +38,14 @@ describe("daemon E2E - timeline window", () => {
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
const assistantTexts = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text);
|
||||
|
||||
expect(assistantTexts).toHaveLength(2);
|
||||
expect(assistantTexts.join("")).toBe(expected);
|
||||
expect(timeline.startCursor?.seq).toBeLessThan(timeline.endCursor?.seq ?? 0);
|
||||
expect(assistantTexts).toEqual([expected]);
|
||||
expect(timeline.startSeq).toBe(timeline.endSeq);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
@@ -73,7 +71,6 @@ describe("daemon E2E - timeline window", () => {
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
const assistantTexts = timeline.entries
|
||||
@@ -82,7 +79,7 @@ describe("daemon E2E - timeline window", () => {
|
||||
|
||||
expect(assistantTexts.join("")).toBe(expected);
|
||||
expect(timeline.hasOlder).toBe(true);
|
||||
expect(timeline.startCursor?.seq).toBeGreaterThan(1);
|
||||
expect(timeline.startSeq).toBeGreaterThan(1);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -289,7 +289,6 @@ async function resolveLatestAssistantMessage(
|
||||
const timeline = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 300,
|
||||
projection: "canonical",
|
||||
});
|
||||
for (let idx = timeline.entries.length - 1; idx >= 0; idx -= 1) {
|
||||
const entry = timeline.entries[idx];
|
||||
|
||||
276
packages/server/src/server/db/db-agent-snapshot-store.test.ts
Normal file
276
packages/server/src/server/db/db-agent-snapshot-store.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
}
|
||||
204
packages/server/src/server/db/db-agent-snapshot-store.ts
Normal file
204
packages/server/src/server/db/db-agent-snapshot-store.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
266
packages/server/src/server/db/db-agent-timeline-store.test.ts
Normal file
266
packages/server/src/server/db/db-agent-timeline-store.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "../agent/agent-timeline-store-types.js";
|
||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./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);
|
||||
});
|
||||
});
|
||||
303
packages/server/src/server/db/db-agent-timeline-store.ts
Normal file
303
packages/server/src/server/db/db-agent-timeline-store.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { and, asc, desc, eq, gt, lt, sql } from "drizzle-orm";
|
||||
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
AgentTimelineStore,
|
||||
AgentTimelineWindow,
|
||||
} from "../agent/agent-timeline-store-types.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import type { PaseoDatabaseHandle } from "./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,
|
||||
};
|
||||
}
|
||||
}
|
||||
81
packages/server/src/server/db/db-project-registry.ts
Normal file
81
packages/server/src/server/db/db-project-registry.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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)
|
||||
.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.id,
|
||||
set: {
|
||||
directory: nextRecord.directory,
|
||||
kind: nextRecord.kind,
|
||||
displayName: nextRecord.displayName,
|
||||
gitRemote: nextRecord.gitRemote,
|
||||
createdAt: nextRecord.createdAt,
|
||||
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));
|
||||
}
|
||||
}
|
||||
199
packages/server/src/server/db/db-workspace-registry.test.ts
Normal file
199
packages/server/src/server/db/db-workspace-registry.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
85
packages/server/src/server/db/db-workspace-registry.ts
Normal file
85
packages/server/src/server/db/db-workspace-registry.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
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)
|
||||
.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.id,
|
||||
set: {
|
||||
projectId: nextRecord.projectId,
|
||||
directory: nextRecord.directory,
|
||||
kind: nextRecord.kind,
|
||||
displayName: nextRecord.displayName,
|
||||
createdAt: nextRecord.createdAt,
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./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");
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
188
packages/server/src/server/db/legacy-agent-snapshot-import.ts
Normal file
188
packages/server/src/server/db/legacy-agent-snapshot-import.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import path from "node:path";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { count } from "drizzle-orm";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { parseStoredAgentRecord, type StoredAgentRecord } from "../agent/agent-storage.js";
|
||||
import { normalizeWorkspaceId } from "../workspace-registry-model.js";
|
||||
import type { PaseoDatabaseHandle } from "./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 records = await readLegacyAgentRecords(path.join(options.paseoHome, "agents"), options.logger);
|
||||
if (records.length === 0) {
|
||||
options.logger.info("Skipping legacy agent snapshot import because no legacy files exist");
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no-legacy-files",
|
||||
};
|
||||
}
|
||||
|
||||
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 })
|
||||
.from(projects)
|
||||
.all();
|
||||
const projectIdsByDirectory = new Map(projectRows.map((row) => [row.directory, 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 displayName =
|
||||
normalizedDirectory.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedDirectory;
|
||||
let projectId = projectIdsByDirectory.get(normalizedDirectory);
|
||||
if (projectId === undefined) {
|
||||
const projectRow = tx
|
||||
.insert(projects)
|
||||
.values({
|
||||
directory: normalizedDirectory,
|
||||
displayName,
|
||||
kind: "directory",
|
||||
gitRemote: null,
|
||||
createdAt: record.createdAt,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: null,
|
||||
})
|
||||
.returning({ id: projects.id })
|
||||
.get();
|
||||
projectId = projectRow!.id;
|
||||
projectIdsByDirectory.set(normalizedDirectory, projectId);
|
||||
}
|
||||
|
||||
const workspaceRow = tx
|
||||
.insert(workspaces)
|
||||
.values({
|
||||
projectId,
|
||||
directory: normalizedDirectory,
|
||||
displayName,
|
||||
kind: "checkout",
|
||||
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));
|
||||
return workspaceId === undefined ? [] : [toAgentSnapshotRowValues({ record, workspaceId })];
|
||||
});
|
||||
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);
|
||||
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<StoredAgentRecord[]> {
|
||||
let entries: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
entries = await fs.readdir(baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recordsById = new Map<string, StoredAgentRecord>();
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith(".json")) {
|
||||
const record = await readRecordFile(path.join(baseDir, entry.name), logger);
|
||||
if (record) {
|
||||
recordsById.set(record.id, record);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let childEntries: Array<import("node:fs").Dirent> = [];
|
||||
try {
|
||||
childEntries = await fs.readdir(path.join(baseDir, entry.name), { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const childEntry of childEntries) {
|
||||
if (!childEntry.isFile() || !childEntry.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
const record = await readRecordFile(path.join(baseDir, entry.name, childEntry.name), logger);
|
||||
if (record) {
|
||||
recordsById.set(record.id, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(recordsById.values());
|
||||
}
|
||||
|
||||
async function readRecordFile(filePath: string, logger: Logger): Promise<StoredAgentRecord | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return parseStoredAgentRecord(JSON.parse(raw));
|
||||
} catch (error) {
|
||||
logger.error({ err: error, filePath }, "Skipping invalid legacy agent snapshot");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasAnyAgentSnapshotRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
|
||||
const rows = await db.select({ count: count() }).from(agentSnapshots);
|
||||
return (rows[0]?.count ?? 0) > 0;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
172
packages/server/src/server/db/legacy-project-workspace-import.ts
Normal file
172
packages/server/src/server/db/legacy-project-workspace-import.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
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";
|
||||
|
||||
// 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 [projectRows, workspaceRows, databaseHasRows] = await Promise.all([
|
||||
readLegacyProjects(projectsPath),
|
||||
readLegacyWorkspaces(workspacesPath),
|
||||
hasAnyProjectWorkspaceRows(options.db),
|
||||
]);
|
||||
|
||||
if (databaseHasRows) {
|
||||
options.logger.info("Skipping legacy project/workspace JSON import because the DB is not empty");
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "database-not-empty",
|
||||
};
|
||||
}
|
||||
|
||||
if (projectRows.length === 0 && workspaceRows.length === 0) {
|
||||
options.logger.info("Skipping legacy project/workspace JSON import because no legacy files exist");
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "no-legacy-files",
|
||||
};
|
||||
}
|
||||
|
||||
options.db.transaction((tx) => {
|
||||
// Insert projects, mapping old format to new schema
|
||||
const projectDirectoryToId = new Map<string, number>();
|
||||
for (const legacy of projectRows) {
|
||||
const row = tx
|
||||
.insert(projects)
|
||||
.values({
|
||||
directory: legacy.rootPath,
|
||||
displayName: legacy.displayName,
|
||||
kind: legacy.kind === "non_git" ? "directory" : legacy.kind,
|
||||
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
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
options.logger.info(
|
||||
{
|
||||
importedProjects: projectRows.length,
|
||||
importedWorkspaces: workspaceRows.length,
|
||||
},
|
||||
"Imported legacy project/workspace JSON into the database",
|
||||
);
|
||||
|
||||
return {
|
||||
status: "imported",
|
||||
importedProjects: projectRows.length,
|
||||
importedWorkspaces: workspaceRows.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function readLegacyProjects(filePath: string) {
|
||||
const raw = await readOptionalJsonFile(filePath);
|
||||
return raw ? z.array(LegacyProjectSchema).parse(raw) : [];
|
||||
}
|
||||
|
||||
async function readLegacyWorkspaces(filePath: string) {
|
||||
const raw = await readOptionalJsonFile(filePath);
|
||||
return raw ? z.array(LegacyWorkspaceSchema).parse(raw) : [];
|
||||
}
|
||||
|
||||
async function readOptionalJsonFile(filePath: string): Promise<unknown | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf8");
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function hasAnyProjectWorkspaceRows(db: PaseoDatabaseHandle["db"]): Promise<boolean> {
|
||||
const [projectCountRows, workspaceCountRows] = await Promise.all([
|
||||
db.select({ count: count() }).from(projects),
|
||||
db.select({ count: count() }).from(workspaces),
|
||||
]);
|
||||
const projectCount = projectCountRows[0]?.count ?? 0;
|
||||
const workspaceCount = workspaceCountRows[0]?.count ?? 0;
|
||||
return projectCount > 0 || workspaceCount > 0;
|
||||
}
|
||||
12
packages/server/src/server/db/migrations.ts
Normal file
12
packages/server/src/server/db/migrations.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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 });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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`)
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "7ccf4685-bd8f-41a6-bafb-44712c1fe0d7",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
13
packages/server/src/server/db/migrations/meta/_journal.json
Normal file
13
packages/server/src/server/db/migrations/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1774405361702,
|
||||
"tag": "0000_sqlite_initial",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
78
packages/server/src/server/db/schema.ts
Normal file
78
packages/server/src/server/db/schema.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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,
|
||||
};
|
||||
316
packages/server/src/server/db/sqlite-contract.test.ts
Normal file
316
packages/server/src/server/db/sqlite-contract.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
31
packages/server/src/server/db/sqlite-database.ts
Normal file
31
packages/server/src/server/db/sqlite-database.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
interface ScriptedAgentBehavior {
|
||||
|
||||
@@ -785,6 +785,7 @@ export class LoopService {
|
||||
model: loop.workerModel ?? loop.model ?? undefined,
|
||||
title: buildWorkerTitle(loop, iteration.index),
|
||||
internal: true,
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -795,6 +796,7 @@ export class LoopService {
|
||||
model: loop.verifierModel ?? loop.model ?? undefined,
|
||||
title: buildVerifierTitle(loop, iteration.index),
|
||||
internal: true,
|
||||
terminal: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +1,6 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import {
|
||||
attachAgentStoragePersistence,
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
} from "./persistence-hooks.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
} from "./agent/agent-sdk-types.js";
|
||||
|
||||
const testLogger = {
|
||||
child: () => testLogger,
|
||||
error: vi.fn(),
|
||||
} as any;
|
||||
|
||||
type ManagedAgentOverrides = Omit<
|
||||
Partial<ManagedAgent>,
|
||||
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
|
||||
> & {
|
||||
config?: Partial<AgentSessionConfig>;
|
||||
pendingPermissions?: Map<string, AgentPermissionRequest>;
|
||||
session?: AgentSession | null;
|
||||
activeForegroundTurnId?: string | null;
|
||||
};
|
||||
|
||||
function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent {
|
||||
const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z");
|
||||
const provider = overrides.provider ?? "claude";
|
||||
const cwd = overrides.cwd ?? "/tmp/project";
|
||||
const lifecycle = overrides.lifecycle ?? "idle";
|
||||
const configOverrides = overrides.config ?? {};
|
||||
const config: AgentSessionConfig = {
|
||||
provider,
|
||||
cwd,
|
||||
modeId: configOverrides.modeId ?? "plan",
|
||||
model: configOverrides.model ?? "claude-3.5-sonnet",
|
||||
extra: configOverrides.extra ?? { claude: { tone: "focused" } },
|
||||
};
|
||||
const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession));
|
||||
const activeForegroundTurnId =
|
||||
overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null);
|
||||
|
||||
const agent: ManagedAgent = {
|
||||
id: overrides.id ?? "agent-1",
|
||||
provider,
|
||||
cwd,
|
||||
session,
|
||||
capabilities: overrides.capabilities ?? {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
config,
|
||||
lifecycle,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
availableModes: overrides.availableModes ?? [],
|
||||
currentModeId: overrides.currentModeId ?? config.modeId ?? null,
|
||||
pendingPermissions: overrides.pendingPermissions ?? new Map<string, AgentPermissionRequest>(),
|
||||
activeForegroundTurnId,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: overrides.timeline ?? [],
|
||||
persistence: overrides.persistence ?? null,
|
||||
historyPrimed: overrides.historyPrimed ?? true,
|
||||
lastUserMessageAt: overrides.lastUserMessageAt ?? now,
|
||||
lastUsage: overrides.lastUsage,
|
||||
lastError: overrides.lastError,
|
||||
};
|
||||
|
||||
return agent;
|
||||
}
|
||||
import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js";
|
||||
|
||||
function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
|
||||
const now = new Date().toISOString();
|
||||
@@ -100,43 +23,6 @@ function createRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord
|
||||
}
|
||||
|
||||
describe("persistence hooks", () => {
|
||||
test("attachAgentStoragePersistence forwards agent snapshots", async () => {
|
||||
const applySnapshot = vi.fn().mockResolvedValue(undefined);
|
||||
let subscriber: (event: any) => void = () => {
|
||||
throw new Error("Agent manager subscriber was not registered");
|
||||
};
|
||||
const agentManager = {
|
||||
subscribe: vi.fn((callback: (event: any) => void) => {
|
||||
subscriber = callback;
|
||||
return () => {
|
||||
subscriber = () => {
|
||||
throw new Error("Agent manager subscriber was not registered");
|
||||
};
|
||||
};
|
||||
}),
|
||||
};
|
||||
attachAgentStoragePersistence(
|
||||
testLogger,
|
||||
agentManager as any,
|
||||
{
|
||||
applySnapshot,
|
||||
list: vi.fn(),
|
||||
} as any,
|
||||
);
|
||||
|
||||
expect(agentManager.subscribe).toHaveBeenCalledTimes(1);
|
||||
const agent = createManagedAgent();
|
||||
subscriber({ type: "agent_state", agent });
|
||||
expect(applySnapshot).toHaveBeenCalledWith(agent);
|
||||
|
||||
subscriber({
|
||||
type: "agent_stream",
|
||||
agentId: agent.id,
|
||||
event: { type: "timeline", item: { type: "assistant_message", text: "hi" } },
|
||||
});
|
||||
expect(applySnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("buildConfigOverrides carries systemPrompt and mcpServers", () => {
|
||||
const record = createRecord({
|
||||
title: "Voice agent (current)",
|
||||
@@ -208,4 +94,23 @@ describe("persistence hooks", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("buildSessionConfig accepts terminal-only providers from the canonical manifest", () => {
|
||||
const record = createRecord({
|
||||
provider: "gemini",
|
||||
persistence: {
|
||||
provider: "gemini",
|
||||
sessionId: "session-123",
|
||||
},
|
||||
config: {
|
||||
terminal: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(buildSessionConfig(record)).toMatchObject({
|
||||
provider: "gemini",
|
||||
cwd: "/tmp/project",
|
||||
terminal: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type pino from "pino";
|
||||
|
||||
type LoggerLike = {
|
||||
child(bindings: Record<string, unknown>): LoggerLike;
|
||||
error(...args: any[]): void;
|
||||
};
|
||||
|
||||
function getLogger(logger: LoggerLike): LoggerLike {
|
||||
return logger.child({ module: "persistence" });
|
||||
}
|
||||
|
||||
type AgentStoragePersistence = Pick<AgentStorage, "applySnapshot" | "list">;
|
||||
type AgentManagerStateSource = Pick<AgentManager, "subscribe">;
|
||||
|
||||
function isKnownProvider(provider: string): provider is AgentProvider {
|
||||
return provider === "claude" || provider === "codex" || provider === "opencode";
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach AgentStorage persistence to an AgentManager instance so every
|
||||
* agent_state snapshot is flushed to disk.
|
||||
*/
|
||||
export function attachAgentStoragePersistence(
|
||||
logger: LoggerLike,
|
||||
agentManager: AgentManagerStateSource,
|
||||
storage: AgentStoragePersistence,
|
||||
): () => void {
|
||||
const log = getLogger(logger);
|
||||
const unsubscribe = agentManager.subscribe((event) => {
|
||||
if (event.type !== "agent_state") {
|
||||
return;
|
||||
}
|
||||
void storage.applySnapshot(event.agent).catch((error) => {
|
||||
log.error({ err: error, agentId: event.agent.id }, "Failed to persist agent snapshot");
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { isValidAgentProvider } from "./agent/provider-manifest.js";
|
||||
|
||||
export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSessionConfig> {
|
||||
return {
|
||||
cwd: record.cwd,
|
||||
terminal: record.config?.terminal ?? undefined,
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||
@@ -54,13 +19,14 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
}
|
||||
|
||||
export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (!isKnownProvider(record.provider)) {
|
||||
if (!isValidAgentProvider(record.provider)) {
|
||||
throw new Error(`Unknown provider '${record.provider}'`);
|
||||
}
|
||||
const overrides = buildConfigOverrides(record);
|
||||
return {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
terminal: overrides.terminal,
|
||||
modeId: overrides.modeId,
|
||||
model: overrides.model,
|
||||
thinkingOptionId: overrides.thinkingOptionId,
|
||||
@@ -71,6 +37,30 @@ export function buildSessionConfig(record: StoredAgentRecord): AgentSessionConfi
|
||||
};
|
||||
}
|
||||
|
||||
export function toAgentPersistenceHandle(
|
||||
logger: pino.Logger,
|
||||
handle: StoredAgentRecord["persistence"],
|
||||
) {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const provider = handle.provider;
|
||||
if (!isValidAgentProvider(provider)) {
|
||||
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`);
|
||||
return null;
|
||||
}
|
||||
if (!handle.sessionId) {
|
||||
logger.warn("Ignoring persistence handle missing sessionId");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider,
|
||||
sessionId: handle.sessionId,
|
||||
nativeHandle: handle.nativeHandle,
|
||||
metadata: handle.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function extractTimestamps(record: StoredAgentRecord): {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
describe("agent loading boundary", () => {
|
||||
test("session runtime code does not directly hydrate provider history", () => {
|
||||
const sessionSource = readFileSync(new URL("./session.ts", import.meta.url), "utf8");
|
||||
const agentLoadingSource = readFileSync(
|
||||
new URL("./agent-loading-service.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(sessionSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
|
||||
expect(agentLoadingSource).not.toMatch(/hydrateTimelineFromProvider\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { AgentLoadingService } from "./agent-loading-service.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
|
||||
import { openPaseoDatabase } from "./db/sqlite-database.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
|
||||
function createStoredAgentRecord(overrides?: Partial<StoredAgentRecord>): StoredAgentRecord {
|
||||
const now = "2026-03-25T00:00:00.000Z";
|
||||
return {
|
||||
id: "agent-compat-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
config: {
|
||||
model: "gpt-5.1-codex-mini",
|
||||
},
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
id: "agent-compat-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentLoadingService", () => {
|
||||
test("ensureAgentLoaded seeds the live timeline from durable rows for an unloaded persisted agent", async () => {
|
||||
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-load-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const database = await openPaseoDatabase(path.join(workspaceRoot, "db"));
|
||||
|
||||
try {
|
||||
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
durableTimelineStore: new DbAgentTimelineStore(database.db),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000301",
|
||||
});
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: manager as any,
|
||||
agentStorage: storage as any,
|
||||
logger,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workspaceRoot,
|
||||
model: "gpt-5.1-codex-mini",
|
||||
});
|
||||
await manager.runAgent(snapshot.id, "say 'timeline test'");
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
rmSync(
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
"paseo-fake-provider-history",
|
||||
"codex",
|
||||
`${snapshot.persistence?.sessionId}.jsonl`,
|
||||
),
|
||||
{ force: true },
|
||||
);
|
||||
await manager.closeAgent(snapshot.id);
|
||||
|
||||
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
|
||||
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.map((row) => row.item)).toEqual([
|
||||
{ type: "assistant_message", text: "timeline test" },
|
||||
]);
|
||||
} finally {
|
||||
await database.close();
|
||||
rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureAgentLoaded succeeds when provider history is absent", async () => {
|
||||
const workspaceRoot = mkdtempSync(path.join(os.tmpdir(), "provider-history-compat-empty-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
|
||||
try {
|
||||
const storage = new AgentStorage(path.join(workspaceRoot, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000302",
|
||||
});
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: manager as any,
|
||||
agentStorage: storage as any,
|
||||
logger,
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workspaceRoot,
|
||||
model: "gpt-5.1-codex-mini",
|
||||
});
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
await manager.closeAgent(snapshot.id);
|
||||
|
||||
const loaded = await service.ensureAgentLoaded({ agentId: snapshot.id });
|
||||
|
||||
expect(loaded.id).toBe(snapshot.id);
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual([]);
|
||||
} finally {
|
||||
rmSync(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ensureAgentLoaded dedupes concurrent cold-load bootstrap", async () => {
|
||||
const deferred = createDeferred<any>();
|
||||
let currentAgent: any = null;
|
||||
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-dedupe" });
|
||||
const agentStorage = {
|
||||
get: vi.fn(async () =>
|
||||
createStoredAgentRecord({
|
||||
id: "agent-compat-dedupe",
|
||||
cwd: "/tmp/dedupe",
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-dedupe",
|
||||
},
|
||||
}),
|
||||
),
|
||||
};
|
||||
const agentManager = {
|
||||
getAgent: vi.fn(() => currentAgent),
|
||||
resumeAgentFromPersistence: vi.fn(async () => deferred.promise),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(),
|
||||
};
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: agentStorage as any,
|
||||
logger: logger as any,
|
||||
});
|
||||
|
||||
const firstLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
|
||||
const secondLoad = service.ensureAgentLoaded({ agentId: "agent-compat-dedupe" });
|
||||
deferred.resolve(snapshot);
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([firstLoad, secondLoad]);
|
||||
|
||||
expect(firstResult).toEqual(snapshot);
|
||||
expect(secondResult).toEqual(snapshot);
|
||||
expect(agentStorage.get).toHaveBeenCalledTimes(1);
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("resumeAgent delegates to manager resume", async () => {
|
||||
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-resume" });
|
||||
const agentManager = {
|
||||
getAgent: vi.fn(() => null),
|
||||
resumeAgentFromPersistence: vi.fn(async () => snapshot),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(),
|
||||
};
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: {
|
||||
get: async () => null,
|
||||
} as any,
|
||||
logger: logger as any,
|
||||
});
|
||||
|
||||
const result = await service.resumeAgent({
|
||||
handle: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-resume",
|
||||
},
|
||||
overrides: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-resume",
|
||||
},
|
||||
{
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test("refreshAgent reloads loaded persisted agents", async () => {
|
||||
const existing = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
|
||||
const reloaded = createCompatibilitySnapshot({ id: "agent-compat-refresh-loaded" });
|
||||
let currentAgent: any = existing;
|
||||
const agentManager = {
|
||||
getAgent: vi.fn(() => currentAgent),
|
||||
resumeAgentFromPersistence: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(async () => {
|
||||
currentAgent = reloaded;
|
||||
return reloaded;
|
||||
}),
|
||||
};
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: {
|
||||
get: async () => null,
|
||||
} as any,
|
||||
logger: logger as any,
|
||||
});
|
||||
|
||||
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-loaded" });
|
||||
|
||||
expect(agentManager.reloadAgentSession).toHaveBeenCalledWith("agent-compat-refresh-loaded");
|
||||
expect(result).toEqual(reloaded);
|
||||
});
|
||||
|
||||
test("refreshAgent keeps loaded non-persisted agents without reloading", async () => {
|
||||
const existing = createCompatibilitySnapshot({
|
||||
id: "agent-compat-refresh-live",
|
||||
persistence: null,
|
||||
});
|
||||
const agentManager = {
|
||||
getAgent: vi.fn(() => existing),
|
||||
resumeAgentFromPersistence: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(),
|
||||
};
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: {
|
||||
get: async () => null,
|
||||
} as any,
|
||||
logger: logger as any,
|
||||
});
|
||||
|
||||
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-live" });
|
||||
|
||||
expect(agentManager.reloadAgentSession).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(existing);
|
||||
});
|
||||
|
||||
test("refreshAgent resumes unloaded persisted agents", async () => {
|
||||
const snapshot = createCompatibilitySnapshot({ id: "agent-compat-refresh-cold" });
|
||||
const record = createStoredAgentRecord({
|
||||
id: "agent-compat-refresh-cold",
|
||||
cwd: "/tmp/refresh-cold",
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-refresh-cold",
|
||||
},
|
||||
});
|
||||
const agentManager = {
|
||||
getAgent: vi.fn(() => null),
|
||||
resumeAgentFromPersistence: vi.fn(async () => snapshot),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(),
|
||||
};
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: {
|
||||
get: vi.fn(async () => record),
|
||||
} as any,
|
||||
logger: logger as any,
|
||||
});
|
||||
|
||||
const result = await service.refreshAgent({ agentId: "agent-compat-refresh-cold" });
|
||||
|
||||
expect(agentManager.resumeAgentFromPersistence).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-refresh-cold",
|
||||
nativeHandle: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
cwd: "/tmp/refresh-cold",
|
||||
modeId: undefined,
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: undefined,
|
||||
title: undefined,
|
||||
extra: undefined,
|
||||
systemPrompt: undefined,
|
||||
mcpServers: undefined,
|
||||
},
|
||||
"agent-compat-refresh-cold",
|
||||
{
|
||||
createdAt: new Date("2026-03-25T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-03-25T00:00:00.000Z"),
|
||||
lastUserMessageAt: null,
|
||||
labels: {},
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test("refreshAgent preserves the unloaded no-persistence error", async () => {
|
||||
const service = new AgentLoadingService({
|
||||
agentManager: {
|
||||
getAgent: vi.fn(() => null),
|
||||
resumeAgentFromPersistence: vi.fn(),
|
||||
createAgent: vi.fn(),
|
||||
reloadAgentSession: vi.fn(),
|
||||
} as any,
|
||||
agentStorage: {
|
||||
get: async () =>
|
||||
createStoredAgentRecord({
|
||||
id: "agent-compat-no-persistence",
|
||||
persistence: null,
|
||||
}),
|
||||
} as any,
|
||||
logger: {
|
||||
child: () => ({
|
||||
child: () => null,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
} as any,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.refreshAgent({ agentId: "agent-compat-no-persistence" }),
|
||||
).rejects.toThrow("Agent agent-compat-no-persistence cannot be refreshed because it lacks persistence");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import { AgentManager } from "../agent/agent-manager.js";
|
||||
import type { ManagedAgent } from "../agent/agent-manager.js";
|
||||
import { AgentStorage } from "../agent/agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "../agent/agent-snapshot-store.js";
|
||||
import type {
|
||||
AgentPromptInput,
|
||||
AgentSessionConfig,
|
||||
@@ -97,7 +97,7 @@ export interface ScheduleServiceOptions {
|
||||
paseoHome: string;
|
||||
logger: Logger;
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
now?: () => Date;
|
||||
runner?: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export class ScheduleService {
|
||||
private readonly store: ScheduleStore;
|
||||
private readonly logger: Logger;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly agentStorage: AgentSnapshotStore;
|
||||
private readonly now: () => Date;
|
||||
private readonly runner: (schedule: StoredSchedule) => Promise<ScheduleExecutionResult>;
|
||||
private readonly runningScheduleIds = new Set<string>();
|
||||
@@ -368,6 +368,9 @@ export class ScheduleService {
|
||||
private async executeSchedule(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
|
||||
if (schedule.target.type === "agent") {
|
||||
const agent = await this.ensureAgentLoaded(schedule.target.agentId);
|
||||
if (agent.terminal) {
|
||||
throw new Error(`Agent ${agent.id} is a terminal agent and cannot be targeted by schedules`);
|
||||
}
|
||||
if (this.agentManager.hasInFlightRun(agent.id)) {
|
||||
throw new Error(`Agent ${agent.id} already has an active run`);
|
||||
}
|
||||
@@ -398,6 +401,7 @@ export class ScheduleService {
|
||||
extra: schedule.target.config.extra,
|
||||
systemPrompt: schedule.target.config.systemPrompt,
|
||||
mcpServers: schedule.target.config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||
terminal: false,
|
||||
};
|
||||
const labels = {
|
||||
"paseo.schedule-id": schedule.id,
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { Session } from "./session.js";
|
||||
|
||||
function createStoredAgentRecord(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: "2026-03-24T00:00:00.000Z",
|
||||
updatedAt: "2026-03-24T00:00:00.000Z",
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
config: null,
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
archivedAt: null,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCompatibilitySnapshot(overrides?: Partial<Record<string, unknown>>) {
|
||||
return {
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionForOwnershipTests(options?: {
|
||||
agentLoadingService?: {
|
||||
ensureAgentLoaded?: (options: { agentId: string }) => Promise<any>;
|
||||
resumeAgent?: (options: {
|
||||
handle: { provider: string; sessionId: string };
|
||||
overrides?: Record<string, unknown>;
|
||||
}) => Promise<any>;
|
||||
refreshAgent?: (options: { agentId: string }) => Promise<any>;
|
||||
};
|
||||
storedRecord?: Record<string, unknown> | null;
|
||||
loadedAgent?: Record<string, unknown> | null;
|
||||
timelineRows?: Array<{ seq: number; item: Record<string, unknown>; timestamp: Date }>;
|
||||
}) {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const agentManager = {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: vi.fn(() => options?.loadedAgent ?? null),
|
||||
createAgent: vi.fn(async () => {
|
||||
throw new Error("Session should delegate unloaded bootstrap to AgentLoadingService");
|
||||
}),
|
||||
resumeAgentFromPersistence: vi.fn(async () => {
|
||||
throw new Error("Session should delegate persistence resume to AgentLoadingService");
|
||||
}),
|
||||
reloadAgentSession: vi.fn(async () => {
|
||||
throw new Error("Session should delegate refresh reload to AgentLoadingService");
|
||||
}),
|
||||
hydrateTimelineFromProvider: vi.fn(async () => {
|
||||
throw new Error("Session should not call hydrateTimelineFromProvider directly");
|
||||
}),
|
||||
getStructuredSendRejection: vi.fn(async () => null),
|
||||
fetchTimeline: vi.fn(async () => ({
|
||||
rows: options?.timelineRows ?? [],
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
})),
|
||||
recordUserMessage: vi.fn(),
|
||||
waitForAgentRunStart: vi.fn(async () => undefined),
|
||||
getTimeline: vi.fn(() => []),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage: (message) => emitted.push(message as any),
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: agentManager as any,
|
||||
agentStorage: {
|
||||
list: async () => (options?.storedRecord ? [options.storedRecord as any] : []),
|
||||
get: async () => (options?.storedRecord as any) ?? null,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
agentLoadingService: options?.agentLoadingService,
|
||||
} as any) as any;
|
||||
|
||||
return { session, emitted, agentManager };
|
||||
}
|
||||
|
||||
describe("provider history compatibility ownership", () => {
|
||||
test("fetch_agent_timeline_request delegates unloaded bootstrap through the compatibility seam", async () => {
|
||||
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
storedRecord: createStoredAgentRecord(),
|
||||
timelineRows: [
|
||||
{
|
||||
seq: 1,
|
||||
item: { type: "assistant_message", text: "rehydrated from provider history" },
|
||||
timestamp: new Date("2026-03-24T00:00:01.000Z"),
|
||||
},
|
||||
],
|
||||
agentLoadingService: {
|
||||
ensureAgentLoaded,
|
||||
},
|
||||
});
|
||||
|
||||
session.buildAgentPayload = vi.fn(async () => ({ id: "agent-1" }));
|
||||
|
||||
await session.handleMessage({
|
||||
type: "fetch_agent_timeline_request",
|
||||
requestId: "req-fetch",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
|
||||
expect(emitted).toContainEqual({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: expect.objectContaining({
|
||||
requestId: "req-fetch",
|
||||
agentId: "agent-1",
|
||||
error: null,
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
seq: 1,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("send_agent_message_request delegates unloaded bootstrap before recording and streaming", async () => {
|
||||
const ensureAgentLoaded = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, agentManager, emitted } = createSessionForOwnershipTests({
|
||||
storedRecord: createStoredAgentRecord(),
|
||||
agentLoadingService: {
|
||||
ensureAgentLoaded,
|
||||
},
|
||||
});
|
||||
|
||||
session.resolveAgentIdentifier = vi.fn(async () => ({ ok: true, agentId: "agent-1" }));
|
||||
session.unarchiveAgentState = vi.fn(async () => true);
|
||||
session.buildAgentPrompt = vi.fn((text: string) => text);
|
||||
session.startAgentStream = vi.fn(() => ({ ok: true }));
|
||||
|
||||
await session.handleMessage({
|
||||
type: "send_agent_message_request",
|
||||
requestId: "req-send",
|
||||
agentId: "agent-1",
|
||||
text: "hello",
|
||||
images: [],
|
||||
messageId: "msg-1",
|
||||
});
|
||||
|
||||
expect(ensureAgentLoaded).toHaveBeenCalledWith({ agentId: "agent-1" });
|
||||
expect(ensureAgentLoaded.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
agentManager.recordUserMessage.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(agentManager.recordUserMessage).toHaveBeenCalledWith("agent-1", "hello", {
|
||||
messageId: "msg-1",
|
||||
emitState: false,
|
||||
});
|
||||
expect(session.startAgentStream).toHaveBeenCalledWith("agent-1", "hello");
|
||||
expect(emitted).toContainEqual({
|
||||
type: "send_agent_message_response",
|
||||
payload: {
|
||||
requestId: "req-send",
|
||||
agentId: "agent-1",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("resume_agent_request delegates persistence bootstrap through the compatibility seam", async () => {
|
||||
const resumeAgent = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
agentLoadingService: {
|
||||
resumeAgent,
|
||||
},
|
||||
});
|
||||
|
||||
session.unarchiveAgentByHandle = vi.fn(async () => undefined);
|
||||
session.unarchiveAgentState = vi.fn(async () => true);
|
||||
session.forwardAgentUpdate = vi.fn(async () => undefined);
|
||||
session.getAgentPayloadById = vi.fn(async () => ({ id: "agent-1" }));
|
||||
|
||||
await session.handleMessage({
|
||||
type: "resume_agent_request",
|
||||
requestId: "req-resume",
|
||||
handle: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
overrides: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resumeAgent).toHaveBeenCalledWith({
|
||||
handle: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
overrides: {
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
expect(emitted).toContainEqual({
|
||||
type: "status",
|
||||
payload: expect.objectContaining({
|
||||
status: "agent_resumed",
|
||||
requestId: "req-resume",
|
||||
agentId: "agent-1",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("refresh_agent_request delegates loaded persisted refresh through the compatibility seam", async () => {
|
||||
const refreshAgent = vi.fn(async () =>
|
||||
createCompatibilitySnapshot({
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
sessionId: "provider-session-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
loadedAgent: createCompatibilitySnapshot(),
|
||||
agentLoadingService: {
|
||||
refreshAgent,
|
||||
},
|
||||
});
|
||||
|
||||
session.unarchiveAgentState = vi.fn(async () => true);
|
||||
session.interruptAgentIfRunning = vi.fn(async () => undefined);
|
||||
session.forwardAgentUpdate = vi.fn(async () => undefined);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "refresh_agent_request",
|
||||
requestId: "req-refresh-loaded",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(session.interruptAgentIfRunning).toHaveBeenCalledWith("agent-1");
|
||||
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
|
||||
expect(emitted).toContainEqual({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_refreshed",
|
||||
requestId: "req-refresh-loaded",
|
||||
agentId: "agent-1",
|
||||
timelineSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("refresh_agent_request delegates unloaded persisted refresh through the compatibility seam", async () => {
|
||||
const refreshAgent = vi.fn(async () => createCompatibilitySnapshot());
|
||||
const { session, emitted } = createSessionForOwnershipTests({
|
||||
storedRecord: createStoredAgentRecord(),
|
||||
agentLoadingService: {
|
||||
refreshAgent,
|
||||
},
|
||||
});
|
||||
|
||||
session.unarchiveAgentState = vi.fn(async () => true);
|
||||
session.interruptAgentIfRunning = vi.fn(async () => undefined);
|
||||
session.forwardAgentUpdate = vi.fn(async () => undefined);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "refresh_agent_request",
|
||||
requestId: "req-refresh-unloaded",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
|
||||
expect(session.interruptAgentIfRunning).not.toHaveBeenCalled();
|
||||
expect(refreshAgent).toHaveBeenCalledWith({ agentId: "agent-1" });
|
||||
expect(emitted).toContainEqual({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_refreshed",
|
||||
requestId: "req-refresh-unloaded",
|
||||
agentId: "agent-1",
|
||||
timelineSize: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Session } from "./session.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
const { watchCalls, watchMock } = vi.hoisted(() => {
|
||||
const hoistedWatchCalls: Array<{
|
||||
@@ -46,15 +51,15 @@ vi.mock("./checkout-git-utils.js", () => ({
|
||||
resolveCheckoutGitDir: resolveCheckoutGitDirMock,
|
||||
}));
|
||||
|
||||
import { Session } from "./session.js";
|
||||
|
||||
function createSessionForWorkspaceGitWatchTests(): {
|
||||
session: Session;
|
||||
emitted: Array<{ type: string; payload: unknown }>;
|
||||
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
|
||||
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
||||
} {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = [];
|
||||
const projects = new Map<string, any>();
|
||||
const workspaces = new Map<string, any>();
|
||||
const projects = new Map<number, ReturnType<typeof createPersistedProjectRecord>>();
|
||||
const workspaces = new Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>();
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
@@ -84,46 +89,48 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (projectId: string) => projects.get(projectId) ?? null,
|
||||
get: async (id: number) => projects.get(id) ?? null,
|
||||
insert: async () => 0,
|
||||
upsert: async (record: any) => {
|
||||
projects.set(record.projectId, record);
|
||||
projects.set(record.id, record);
|
||||
},
|
||||
archive: async (projectId: string, archivedAt: string) => {
|
||||
const existing = projects.get(projectId);
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = projects.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
projects.set(projectId, {
|
||||
projects.set(id, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
},
|
||||
remove: async (projectId: string) => {
|
||||
projects.delete(projectId);
|
||||
remove: async (id: number) => {
|
||||
projects.delete(id);
|
||||
},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
get: async (id: number) => workspaces.get(id) ?? null,
|
||||
insert: async () => 0,
|
||||
upsert: async (record: any) => {
|
||||
workspaces.set(record.workspaceId, record);
|
||||
workspaces.set(record.id, record);
|
||||
},
|
||||
archive: async (workspaceId: string, archivedAt: string) => {
|
||||
const existing = workspaces.get(workspaceId);
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
workspaces.set(workspaceId, {
|
||||
workspaces.set(id, {
|
||||
...existing,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
});
|
||||
},
|
||||
remove: async (workspaceId: string) => {
|
||||
workspaces.delete(workspaceId);
|
||||
remove: async (id: number) => {
|
||||
workspaces.delete(id);
|
||||
},
|
||||
} as any,
|
||||
checkoutDiffManager: {
|
||||
@@ -148,12 +155,43 @@ function createSessionForWorkspaceGitWatchTests(): {
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
session.listAgentPayloads = async () => [];
|
||||
(session as any).listAgentPayloads = async () => [];
|
||||
|
||||
return {
|
||||
session,
|
||||
emitted,
|
||||
};
|
||||
return { session, emitted, projects, workspaces };
|
||||
}
|
||||
|
||||
function seedGitWorkspace(input: {
|
||||
projects: Map<number, ReturnType<typeof createPersistedProjectRecord>>;
|
||||
workspaces: Map<number, ReturnType<typeof createPersistedWorkspaceRecord>>;
|
||||
projectId: number;
|
||||
workspaceId: number;
|
||||
cwd: string;
|
||||
name: string;
|
||||
}) {
|
||||
input.projects.set(
|
||||
input.projectId,
|
||||
createPersistedProjectRecord({
|
||||
id: input.projectId,
|
||||
directory: "/tmp/repo",
|
||||
displayName: "repo",
|
||||
kind: "git",
|
||||
gitRemote: "https://github.com/acme/repo.git",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
input.workspaces.set(
|
||||
input.workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: input.workspaceId,
|
||||
projectId: input.projectId,
|
||||
directory: input.cwd,
|
||||
displayName: input.name,
|
||||
kind: "checkout",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("workspace git watch targets", () => {
|
||||
@@ -170,21 +208,17 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
test("debounces watcher events and skips unchanged branch/diff snapshots", async () => {
|
||||
const { session, emitted } = createSessionForWorkspaceGitWatchTests();
|
||||
const { session, emitted, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = session as any;
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: "repo",
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: 1,
|
||||
workspaceId: 10,
|
||||
cwd: "/tmp/repo",
|
||||
name: "main",
|
||||
});
|
||||
|
||||
resolveCheckoutGitDirMock.mockResolvedValue("/tmp/repo/.git");
|
||||
sessionAny.workspaceUpdatesSubscription = {
|
||||
subscriptionId: "sub-1",
|
||||
@@ -192,15 +226,14 @@ describe("workspace git watch targets", () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
};
|
||||
sessionAny.reconcileActiveWorkspaceRecords = async () => new Set();
|
||||
|
||||
let descriptor = {
|
||||
id: "/tmp/repo",
|
||||
projectId: "/tmp/repo",
|
||||
id: 10,
|
||||
projectId: 1,
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/tmp/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
@@ -209,8 +242,7 @@ describe("workspace git watch targets", () => {
|
||||
|
||||
sessionAny.listWorkspaceDescriptorsSnapshot = async () => [descriptor];
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo");
|
||||
sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([descriptor]);
|
||||
|
||||
expect(watchCalls.map((entry) => entry.path).sort()).toEqual([
|
||||
"/tmp/repo/.git/HEAD",
|
||||
@@ -237,7 +269,7 @@ describe("workspace git watch targets", () => {
|
||||
expect(workspaceUpdates[0]?.payload).toMatchObject({
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "/tmp/repo",
|
||||
id: 10,
|
||||
name: "renamed-branch",
|
||||
diffStat: { additions: 1, deletions: 0 },
|
||||
},
|
||||
@@ -256,29 +288,45 @@ describe("workspace git watch targets", () => {
|
||||
});
|
||||
|
||||
test("closes watchers when a workspace is archived and when the session closes", async () => {
|
||||
const { session } = createSessionForWorkspaceGitWatchTests();
|
||||
const { session, projects, workspaces } = createSessionForWorkspaceGitWatchTests();
|
||||
const sessionAny = session as any;
|
||||
|
||||
sessionAny.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: cwd,
|
||||
projectName: path.basename(cwd),
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: "https://github.com/acme/repo.git",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
},
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: 2,
|
||||
workspaceId: 20,
|
||||
cwd: "/tmp/repo-one",
|
||||
name: "main",
|
||||
});
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
projectId: 3,
|
||||
workspaceId: 30,
|
||||
cwd: "/tmp/repo-two",
|
||||
name: "main",
|
||||
});
|
||||
|
||||
resolveCheckoutGitDirMock.mockImplementation(async (cwd: string) => path.join(cwd, ".git"));
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-one");
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||
{
|
||||
id: 20,
|
||||
projectId: 2,
|
||||
projectDisplayName: "repo-one",
|
||||
projectRootPath: "/tmp/repo-one",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
]);
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||
expect(watchCalls).toHaveLength(2);
|
||||
|
||||
await sessionAny.archiveWorkspaceRecord("/tmp/repo-one", "2026-03-21T00:00:00.000Z");
|
||||
await sessionAny.archiveWorkspaceRecord(20, "2026-03-21T00:00:00.000Z");
|
||||
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(0);
|
||||
expect(watchCalls.every((entry) => entry.close.mock.calls.length === 1)).toBe(true);
|
||||
@@ -286,7 +334,19 @@ describe("workspace git watch targets", () => {
|
||||
watchCalls.length = 0;
|
||||
watchMock.mockClear();
|
||||
|
||||
await sessionAny.ensureWorkspaceRegistered("/tmp/repo-two");
|
||||
await sessionAny.primeWorkspaceGitWatchFingerprints([
|
||||
{
|
||||
id: 30,
|
||||
projectId: 3,
|
||||
projectDisplayName: "repo-two",
|
||||
projectRootPath: "/tmp/repo-two",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "main",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
},
|
||||
]);
|
||||
expect(sessionAny.workspaceGitWatchTargets.size).toBe(1);
|
||||
expect(watchCalls).toHaveLength(2);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
184
packages/server/src/server/snapshot-mutation-ownership.test.ts
Normal file
184
packages/server/src/server/snapshot-mutation-ownership.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { Session } from "./session.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
import { projects, workspaces } from "./db/schema.js";
|
||||
|
||||
describe("snapshot mutation ownership boundary", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("daemon live mutations write one durable snapshot through the manager-owned path", async () => {
|
||||
const daemonHandle = await createTestPaseoDaemon();
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "snapshot-owner-live-"));
|
||||
|
||||
try {
|
||||
const db = (daemonHandle.daemon.agentStorage as any).db;
|
||||
const [projectRow] = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
directory: cwd,
|
||||
displayName: "test-project",
|
||||
kind: "directory",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning({ id: projects.id });
|
||||
const [workspaceRow] = await db
|
||||
.insert(workspaces)
|
||||
.values({
|
||||
projectId: projectRow!.id,
|
||||
directory: cwd,
|
||||
displayName: "test-workspace",
|
||||
kind: "checkout",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.returning({ id: workspaces.id });
|
||||
|
||||
const snapshot = await daemonHandle.daemon.agentManager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd,
|
||||
model: "gpt-5.2-codex",
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: workspaceRow!.id },
|
||||
);
|
||||
await daemonHandle.daemon.agentManager.flush();
|
||||
|
||||
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
|
||||
|
||||
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
|
||||
await daemonHandle.daemon.agentManager.flush();
|
||||
|
||||
expect(applySnapshotSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
|
||||
expect(persisted?.config?.model).toBe("gpt-5.4");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
await daemonHandle.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("session runtime flows delegate snapshot mutations to agent manager without direct storage writes", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const archiveSnapshot = vi.fn(async (_agentId: string, archivedAt: string) => ({
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
createdAt: "2026-03-24T00:00:00.000Z",
|
||||
updatedAt: archivedAt,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
config: null,
|
||||
persistence: null,
|
||||
archivedAt,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
}));
|
||||
const unarchiveSnapshot = vi.fn(async () => true);
|
||||
const unarchiveSnapshotByHandle = vi.fn(async () => undefined);
|
||||
const updateAgentMetadata = vi.fn(async () => undefined);
|
||||
const directStorageWrite = vi.fn(async () => {
|
||||
throw new Error("Session should not write snapshots directly");
|
||||
});
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
onMessage,
|
||||
logger: logger as any,
|
||||
downloadTokenStore: {} as any,
|
||||
pushTokenStore: {} as any,
|
||||
paseoHome: "/tmp/paseo-test",
|
||||
agentManager: {
|
||||
subscribe: () => () => {},
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
archiveSnapshot,
|
||||
unarchiveSnapshot,
|
||||
unarchiveSnapshotByHandle,
|
||||
updateAgentMetadata,
|
||||
} as any,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
applySnapshot: directStorageWrite,
|
||||
upsert: directStorageWrite,
|
||||
} as any,
|
||||
projectRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
} as any,
|
||||
createAgentMcpTransport: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
stt: null,
|
||||
tts: null,
|
||||
terminalManager: null,
|
||||
}) as any;
|
||||
|
||||
const archiveResult = await session.archiveAgentState("agent-1");
|
||||
expect(archiveSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(archiveResult.archivedAt).toBeTruthy();
|
||||
|
||||
await session.unarchiveAgentState("agent-1");
|
||||
expect(unarchiveSnapshot).toHaveBeenCalledWith("agent-1");
|
||||
|
||||
const handle = { provider: "codex", sessionId: "session-1" };
|
||||
await session.unarchiveAgentByHandle(handle);
|
||||
expect(unarchiveSnapshotByHandle).toHaveBeenCalledWith(handle);
|
||||
|
||||
await session.handleUpdateAgentRequest(
|
||||
"agent-1",
|
||||
"Renamed agent",
|
||||
{ lane: "phase-1a" },
|
||||
"req-1",
|
||||
);
|
||||
expect(updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||
title: "Renamed agent",
|
||||
labels: { lane: "phase-1a" },
|
||||
});
|
||||
expect(onMessage).toHaveBeenCalledWith({
|
||||
type: "update_agent_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent-1",
|
||||
accepted: true,
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(directStorageWrite).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
type Deferred<T> = {
|
||||
@@ -929,6 +930,9 @@ export function createTestAgentClients(): Record<string, AgentClient> {
|
||||
return {
|
||||
claude: new FakeAgentClient("claude"),
|
||||
codex: new FakeAgentClient("codex"),
|
||||
gemini: new FakeAgentClient("gemini"),
|
||||
amp: new FakeAgentClient("amp"),
|
||||
aider: new FakeAgentClient("aider"),
|
||||
opencode: new FakeAgentClient("opencode"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(async () => null),
|
||||
...agentManagerOverrides,
|
||||
};
|
||||
|
||||
@@ -109,22 +110,20 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses assistant preview text for push notifications with markdown removed", () => {
|
||||
it("uses assistant preview text for push notifications with markdown removed", async () => {
|
||||
const getLastAssistantMessage = vi.fn(
|
||||
async () => "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
);
|
||||
const { server } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
timeline: [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
getLastAssistantMessage,
|
||||
});
|
||||
|
||||
(server as any).broadcastAgentAttention({
|
||||
await (server as any).broadcastAgentAttention({
|
||||
agentId: "agent-1",
|
||||
provider: "claude",
|
||||
reason: "finished",
|
||||
@@ -139,30 +138,28 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
reason: "finished",
|
||||
},
|
||||
});
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("sends push notifications regardless of UI label presence", () => {
|
||||
it("sends push notifications regardless of UI label presence", async () => {
|
||||
const getLastAssistantMessage = vi.fn(async () => "Done.");
|
||||
const { server } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
labels: {},
|
||||
timeline: [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
getLastAssistantMessage,
|
||||
});
|
||||
|
||||
(server as any).broadcastAgentAttention({
|
||||
await (server as any).broadcastAgentAttention({
|
||||
agentId: "agent-2",
|
||||
provider: "claude",
|
||||
reason: "finished",
|
||||
});
|
||||
|
||||
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { join } from "path";
|
||||
import { hostname as getHostname } from "node:os";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage } from "./agent/agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import type { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type pino from "pino";
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./agent-attention-policy.js";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
findLatestAssistantMessageFromTimeline,
|
||||
findLatestPermissionRequest,
|
||||
} from "../shared/agent-attention-notification.js";
|
||||
|
||||
@@ -70,6 +69,7 @@ function createNoopProjectRegistry(): ProjectRegistry {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
insert: async () => 0,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -82,6 +82,7 @@ function createNoopWorkspaceRegistry(): WorkspaceRegistry {
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => [],
|
||||
get: async () => null,
|
||||
insert: async () => 0,
|
||||
upsert: async () => {},
|
||||
archive: async () => {},
|
||||
remove: async () => {},
|
||||
@@ -227,7 +228,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
private readonly serverId: string;
|
||||
private readonly daemonVersion: string;
|
||||
private readonly agentManager: AgentManager;
|
||||
private readonly agentStorage: AgentStorage;
|
||||
private readonly agentStorage: AgentSnapshotStore;
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly chatService: FileBackedChatService;
|
||||
@@ -283,7 +284,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
logger: pino.Logger,
|
||||
serverId: string,
|
||||
agentManager: AgentManager,
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
downloadTokenStore: DownloadTokenStore,
|
||||
paseoHome: string,
|
||||
createAgentMcpTransport: AgentMcpTransportFactory,
|
||||
@@ -355,7 +356,9 @@ export class VoiceAssistantWebSocketServer {
|
||||
this.pushService = new PushService(pushLogger, this.pushTokenStore);
|
||||
|
||||
this.agentManager.setAgentAttentionCallback((params) => {
|
||||
this.broadcastAgentAttention(params);
|
||||
void this.broadcastAgentAttention(params).catch((err) => {
|
||||
this.logger.warn({ err, agentId: params.agentId }, "Failed to broadcast agent attention");
|
||||
});
|
||||
});
|
||||
|
||||
const { allowedOrigins, allowedHosts } = wsConfig;
|
||||
@@ -1319,11 +1322,11 @@ export class VoiceAssistantWebSocketServer {
|
||||
};
|
||||
}
|
||||
|
||||
private broadcastAgentAttention(params: {
|
||||
private async broadcastAgentAttention(params: {
|
||||
agentId: string;
|
||||
provider: AgentProvider;
|
||||
reason: "finished" | "error" | "permission";
|
||||
}): void {
|
||||
}): Promise<void> {
|
||||
const clientEntries: Array<{
|
||||
ws: WebSocketLike;
|
||||
state: ClientAttentionState;
|
||||
@@ -1338,11 +1341,12 @@ export class VoiceAssistantWebSocketServer {
|
||||
|
||||
const allStates = clientEntries.map((e) => e.state);
|
||||
const agent = this.agentManager.getAgent(params.agentId);
|
||||
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
||||
const notification = buildAgentAttentionNotificationPayload({
|
||||
reason: params.reason,
|
||||
serverId: this.serverId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null,
|
||||
assistantMessage,
|
||||
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
||||
});
|
||||
|
||||
|
||||
82
packages/server/src/server/workspace-git-metadata.ts
Normal file
82
packages/server/src/server/workspace-git-metadata.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { execSync } from "child_process";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
|
||||
export type WorkspaceGitMetadata = {
|
||||
projectKind: "git" | "directory";
|
||||
projectDisplayName: string;
|
||||
workspaceDisplayName: string;
|
||||
gitRemote: string | null;
|
||||
};
|
||||
|
||||
export function readGitCommand(cwd: string, command: string): string | null {
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const trimmed = output.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||
let cleaned = remoteUrl.trim();
|
||||
if (!cleaned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else if (cleaned.startsWith("https://github.com/")) {
|
||||
cleaned = cleaned.slice("https://github.com/".length);
|
||||
} else if (cleaned.startsWith("http://github.com/")) {
|
||||
cleaned = cleaned.slice("http://github.com/".length);
|
||||
} else {
|
||||
const marker = "github.com/";
|
||||
const markerIndex = cleaned.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
cleaned = cleaned.slice(markerIndex + marker.length);
|
||||
}
|
||||
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function detectWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
directoryName: string,
|
||||
): WorkspaceGitMetadata {
|
||||
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
|
||||
if (!gitDir) {
|
||||
return {
|
||||
projectKind: "directory",
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
};
|
||||
}
|
||||
|
||||
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
|
||||
const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
|
||||
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
|
||||
|
||||
return {
|
||||
projectKind: "git",
|
||||
projectDisplayName: githubRepo ?? directoryName,
|
||||
workspaceDisplayName: branchName ?? directoryName,
|
||||
gitRemote,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
|
||||
function createTestRegistries() {
|
||||
const projects = new Map<number, PersistedProjectRecord>();
|
||||
const workspaces = new Map<number, PersistedWorkspaceRecord>();
|
||||
let nextProjectId = 1;
|
||||
let nextWorkspaceId = 1;
|
||||
|
||||
const projectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: number) => projects.get(id) ?? null,
|
||||
insert: async (record: Omit<PersistedProjectRecord, "id">) => {
|
||||
const id = nextProjectId++;
|
||||
projects.set(id, createPersistedProjectRecord({ id, ...record }));
|
||||
return id;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.id, record);
|
||||
},
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = projects.get(id);
|
||||
if (existing) {
|
||||
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
}
|
||||
},
|
||||
remove: async (id: number) => {
|
||||
projects.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (id: number) => workspaces.get(id) ?? null,
|
||||
insert: async (record: Omit<PersistedWorkspaceRecord, "id">) => {
|
||||
const id = nextWorkspaceId++;
|
||||
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
|
||||
return id;
|
||||
},
|
||||
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||
workspaces.set(record.id, record);
|
||||
},
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (existing) {
|
||||
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
}
|
||||
},
|
||||
remove: async (id: number) => {
|
||||
workspaces.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
return { projects, workspaces, projectRegistry, workspaceRegistry };
|
||||
}
|
||||
|
||||
function createTestLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
return logger as any;
|
||||
}
|
||||
|
||||
function createTempGitRepo(prefix: string): string {
|
||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const dir = realpathSync(raw);
|
||||
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
|
||||
writeFileSync(path.join(dir, "README.md"), "# Test\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const timestamp = "2025-01-01T00:00:00.000Z";
|
||||
|
||||
describe("WorkspaceReconciliationService", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("archives workspaces whose directories no longer exist", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-test",
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-test",
|
||||
kind: "checkout",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied.length).toBeGreaterThanOrEqual(1);
|
||||
const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived");
|
||||
expect(wsChange).toBeDefined();
|
||||
expect(workspaces.get(1)!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("archives orphaned projects after all workspaces are archived", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "checkout",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
||||
expect(projChange).toBeDefined();
|
||||
expect(projects.get(1)!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("updates project kind when a directory becomes a git repo", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "reconcile-git-init-"));
|
||||
const resolved = realpathSync(dir);
|
||||
tempDirs.push(resolved);
|
||||
writeFileSync(path.join(resolved, "README.md"), "# Test\n");
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: resolved,
|
||||
kind: "directory",
|
||||
displayName: path.basename(resolved),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: resolved,
|
||||
kind: "checkout",
|
||||
displayName: path.basename(resolved),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize as git repo
|
||||
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git add .", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get(1)!.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("updates project display name when git remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: dir,
|
||||
kind: "git",
|
||||
displayName: "old-owner/old-repo",
|
||||
gitRemote: "git@github.com:old-owner/old-repo.git",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: dir,
|
||||
kind: "checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Change the remote
|
||||
execSync("git remote add origin git@github.com:new-owner/new-repo.git", {
|
||||
cwd: dir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get(1)!.displayName).toBe("new-owner/new-repo");
|
||||
expect(projects.get(1)!.gitRemote).toBe("git@github.com:new-owner/new-repo.git");
|
||||
});
|
||||
|
||||
test("updates workspace display name when branch changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" });
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: dir,
|
||||
kind: "git",
|
||||
displayName: path.basename(dir),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: dir,
|
||||
kind: "checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
|
||||
expect(wsUpdate).toBeDefined();
|
||||
expect(workspaces.get(1)!.displayName).toBe("feature-branch");
|
||||
});
|
||||
|
||||
test("does not modify already-archived records", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-archived",
|
||||
kind: "directory",
|
||||
displayName: "archived",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-archived",
|
||||
kind: "checkout",
|
||||
displayName: "archived",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("calls onChanges callback when changes are applied", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
kind: "checkout",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const onChanges = vi.fn();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
onChanges,
|
||||
});
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(onChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
239
packages/server/src/server/workspace-reconciliation-service.ts
Normal file
239
packages/server/src/server/workspace-reconciliation-service.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
export type ReconciliationChange =
|
||||
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
|
||||
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
|
||||
| {
|
||||
kind: "project_updated";
|
||||
projectId: number;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
workspaceId: number;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
|
||||
};
|
||||
|
||||
export type ReconciliationResult = {
|
||||
changesApplied: ReconciliationChange[];
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type WorkspaceReconciliationServiceOptions = {
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: pino.Logger;
|
||||
intervalMs?: number;
|
||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||
};
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||
this.onChanges = options.onChanges ?? null;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
|
||||
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
|
||||
// Run once immediately on start
|
||||
void this.runSafe();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runOnce(): Promise<ReconciliationResult> {
|
||||
return this.reconcile();
|
||||
}
|
||||
|
||||
private async runSafe(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const result = await this.reconcile();
|
||||
if (result.changesApplied.length > 0) {
|
||||
this.logger.info(
|
||||
{ changeCount: result.changesApplied.length, durationMs: result.durationMs },
|
||||
"Reconciliation pass completed with changes",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Reconciliation pass failed");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<ReconciliationResult> {
|
||||
const start = Date.now();
|
||||
const changes: ReconciliationChange[] = [];
|
||||
|
||||
const allProjects = await this.projectRegistry.list();
|
||||
const allWorkspaces = await this.workspaceRegistry.list();
|
||||
|
||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
|
||||
|
||||
const workspacesByProject = new Map<number, PersistedWorkspaceRecord[]>();
|
||||
for (const workspace of activeWorkspaces) {
|
||||
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
list.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, list);
|
||||
}
|
||||
|
||||
// 1. Archive workspaces whose directories no longer exist
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (!existsSync(workspace.directory)) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.archive(workspace.id, timestamp);
|
||||
changes.push({
|
||||
kind: "workspace_archived",
|
||||
workspaceId: workspace.id,
|
||||
directory: workspace.directory,
|
||||
reason: "directory_missing",
|
||||
});
|
||||
|
||||
// Update the in-memory list for the project orphan check below
|
||||
const siblings = workspacesByProject.get(workspace.projectId);
|
||||
if (siblings) {
|
||||
const updated = siblings.filter((w) => w.id !== workspace.id);
|
||||
workspacesByProject.set(workspace.projectId, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Archive orphaned projects (all workspaces archived/removed)
|
||||
for (const project of activeProjects) {
|
||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
||||
if (siblings.length === 0) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.id, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.id,
|
||||
directory: project.directory,
|
||||
reason: "no_active_workspaces",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist
|
||||
for (const project of activeProjects) {
|
||||
if (project.archivedAt) continue;
|
||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
||||
if (siblings.length === 0) continue;
|
||||
if (!existsSync(project.directory)) continue;
|
||||
|
||||
const directoryName =
|
||||
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
|
||||
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
|
||||
|
||||
const projectUpdates: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">
|
||||
> = {};
|
||||
|
||||
// Detect kind change: directory → git
|
||||
if (project.kind !== currentGit.projectKind) {
|
||||
projectUpdates.kind = currentGit.projectKind;
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
projectUpdates.gitRemote = currentGit.gitRemote;
|
||||
}
|
||||
|
||||
// Detect display name change (e.g. remote renamed)
|
||||
if (
|
||||
project.kind === "git" &&
|
||||
currentGit.projectKind === "git" &&
|
||||
project.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) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.upsert({
|
||||
...project,
|
||||
...projectUpdates,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: project.id,
|
||||
directory: project.directory,
|
||||
fields: projectUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Reconcile workspace display names (branch name changes)
|
||||
for (const workspace of siblings) {
|
||||
if (workspace.kind !== "checkout") continue;
|
||||
if (!existsSync(workspace.directory)) continue;
|
||||
|
||||
const wsDirName =
|
||||
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
|
||||
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
|
||||
|
||||
if (
|
||||
wsGit.projectKind === "git" &&
|
||||
workspace.displayName !== wsGit.workspaceDisplayName
|
||||
) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.upsert({
|
||||
...workspace,
|
||||
displayName: wsGit.workspaceDisplayName,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.id,
|
||||
directory: workspace.directory,
|
||||
fields: { displayName: wsGit.workspaceDisplayName },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0 && this.onChanges) {
|
||||
this.onChanges(changes);
|
||||
}
|
||||
|
||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||
}
|
||||
}
|
||||
@@ -1,167 +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 { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
|
||||
|
||||
describe("bootstrapWorkspaceRegistries", () => {
|
||||
let tmpDir: string;
|
||||
let paseoHome: string;
|
||||
let agentStorage: AgentStorage;
|
||||
let projectRegistry: FileBackedProjectRegistry;
|
||||
let workspaceRegistry: FileBackedWorkspaceRegistry;
|
||||
const logger = createTestLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(path.join(os.tmpdir(), "workspace-bootstrap-"));
|
||||
paseoHome = path.join(tmpDir, ".paseo");
|
||||
agentStorage = new AgentStorage(path.join(paseoHome, "agents"), logger);
|
||||
projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(paseoHome, "projects", "projects.json"),
|
||||
logger,
|
||||
);
|
||||
workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(paseoHome, "projects", "workspaces.json"),
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("materializes workspace registries from non-archived agent records", async () => {
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/non-git-project",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
await agentStorage.upsert({
|
||||
id: "agent-2",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/non-git-project",
|
||||
createdAt: "2026-03-01T01:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-03T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "running",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
await agentStorage.upsert({
|
||||
id: "agent-archived",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/archived-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: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
});
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
});
|
||||
|
||||
const workspaces = await workspaceRegistry.list();
|
||||
expect(workspaces).toHaveLength(1);
|
||||
expect(workspaces[0]?.workspaceId).toBe("/tmp/non-git-project");
|
||||
expect(workspaces[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||
expect(workspaces[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
|
||||
const projects = await projectRegistry.list();
|
||||
expect(projects).toHaveLength(1);
|
||||
expect(projects[0]?.projectId).toBe("/tmp/non-git-project");
|
||||
expect(projects[0]?.createdAt).toBe("2026-03-01T00:00:00.000Z");
|
||||
expect(projects[0]?.updatedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
});
|
||||
|
||||
test("does not rematerialize when registry files already exist", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await workspaceRegistry.initialize();
|
||||
await projectRegistry.upsert({
|
||||
projectId: "/tmp/existing",
|
||||
rootPath: "/tmp/existing",
|
||||
kind: "non_git",
|
||||
displayName: "existing",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
});
|
||||
await workspaceRegistry.upsert({
|
||||
workspaceId: "/tmp/existing",
|
||||
projectId: "/tmp/existing",
|
||||
cwd: "/tmp/existing",
|
||||
kind: "directory",
|
||||
displayName: "existing",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await agentStorage.initialize();
|
||||
await agentStorage.upsert({
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
cwd: "/tmp/another-project",
|
||||
createdAt: "2026-03-02T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
lastActivityAt: "2026-03-02T00:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: null,
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
lastModeId: null,
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
});
|
||||
|
||||
await bootstrapWorkspaceRegistries({
|
||||
paseoHome,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
expect(await workspaceRegistry.list()).toHaveLength(1);
|
||||
expect((await workspaceRegistry.list())[0]?.workspaceId).toBe("/tmp/existing");
|
||||
});
|
||||
});
|
||||
@@ -1,142 +0,0 @@
|
||||
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,
|
||||
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, StoredAgentRecord[]>();
|
||||
for (const record of activeRecords) {
|
||||
const workspaceId = normalizeWorkspaceId(record.cwd);
|
||||
const existing = recordsByWorkspaceId.get(workspaceId) ?? [];
|
||||
existing.push(record);
|
||||
recordsByWorkspaceId.set(workspaceId, existing);
|
||||
}
|
||||
|
||||
const projectRanges = new Map<string, { createdAt: string | null; updatedAt: string | null }>();
|
||||
|
||||
for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) {
|
||||
const placement = await buildProjectPlacementForCwd({
|
||||
cwd: workspaceId,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
|
||||
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,75 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { detectStaleWorkspaces } from "./workspace-registry-model.js";
|
||||
import { createPersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
function createWorkspaceRecord(workspaceId: string) {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: workspaceId,
|
||||
cwd: workspaceId,
|
||||
kind: "directory",
|
||||
displayName: workspaceId.split("/").at(-1) ?? workspaceId,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
describe("detectStaleWorkspaces", () => {
|
||||
test("returns workspace ids whose directories no longer exist", async () => {
|
||||
const checkDirectoryExists = vi.fn(async (cwd: string) => cwd !== "/tmp/missing");
|
||||
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/existing"),
|
||||
createWorkspaceRecord("/tmp/missing"),
|
||||
],
|
||||
agentRecords: [],
|
||||
checkDirectoryExists,
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/missing"]);
|
||||
expect(checkDirectoryExists.mock.calls).toEqual([["/tmp/existing"], ["/tmp/missing"]]);
|
||||
});
|
||||
|
||||
test("returns workspace ids when all matching agents are archived", async () => {
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [createWorkspaceRecord("/tmp/repo"), createWorkspaceRecord("/tmp/other")],
|
||||
agentRecords: [
|
||||
{
|
||||
cwd: "/tmp/repo",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
cwd: "/tmp/other",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
checkDirectoryExists: async () => true,
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual(["/tmp/repo"]);
|
||||
});
|
||||
|
||||
test("keeps workspaces with no agents or at least one active agent", async () => {
|
||||
const staleWorkspaceIds = await detectStaleWorkspaces({
|
||||
activeWorkspaces: [
|
||||
createWorkspaceRecord("/tmp/active"),
|
||||
createWorkspaceRecord("/tmp/no-agents"),
|
||||
],
|
||||
agentRecords: [
|
||||
{
|
||||
cwd: "/tmp/active",
|
||||
archivedAt: "2026-03-02T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
cwd: "/tmp/active/../active",
|
||||
archivedAt: null,
|
||||
},
|
||||
],
|
||||
checkDirectoryExists: async () => true,
|
||||
});
|
||||
|
||||
expect(Array.from(staleWorkspaceIds)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,7 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { getCheckoutStatusLite } from "../utils/checkout-git.js";
|
||||
import type { ProjectCheckoutLitePayload, ProjectPlacementPayload } from "../shared/messages.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
|
||||
export type PersistedProjectKind = "git" | "non_git";
|
||||
export type PersistedWorkspaceKind = "local_checkout" | "worktree" | "directory";
|
||||
export type StaleWorkspaceAgentRecord = {
|
||||
cwd: string;
|
||||
archivedAt: string | null;
|
||||
};
|
||||
|
||||
export type DetectStaleWorkspacesInput = {
|
||||
activeWorkspaces: PersistedWorkspaceRecord[];
|
||||
agentRecords: StaleWorkspaceAgentRecord[];
|
||||
checkDirectoryExists: (cwd: string) => Promise<boolean>;
|
||||
};
|
||||
export type PersistedProjectKind = "git" | "directory";
|
||||
export type PersistedWorkspaceKind = "checkout" | "worktree";
|
||||
|
||||
export function normalizeWorkspaceId(cwd: string): string {
|
||||
const trimmed = cwd.trim();
|
||||
@@ -24,212 +10,3 @@ export function normalizeWorkspaceId(cwd: string): string {
|
||||
}
|
||||
return resolve(trimmed);
|
||||
}
|
||||
|
||||
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>();
|
||||
const cwdsWithActiveAgents = new Set<string>();
|
||||
const cwdsWithAnyAgent = new Set<string>();
|
||||
|
||||
for (const agent of input.agentRecords) {
|
||||
const normalizedCwd = normalizeWorkspaceId(agent.cwd);
|
||||
cwdsWithAnyAgent.add(normalizedCwd);
|
||||
if (!agent.archivedAt) {
|
||||
cwdsWithActiveAgents.add(normalizedCwd);
|
||||
}
|
||||
}
|
||||
|
||||
for (const workspace of input.activeWorkspaces) {
|
||||
const dirExists = await input.checkDirectoryExists(workspace.cwd);
|
||||
if (!dirExists) {
|
||||
staleWorkspaceIds.add(workspace.workspaceId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasAgents = cwdsWithAnyAgent.has(workspace.workspaceId);
|
||||
const hasActiveAgents = cwdsWithActiveAgents.has(workspace.workspaceId);
|
||||
if (hasAgents && !hasActiveAgents) {
|
||||
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,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.isPaseoOwnedWorktree && status.mainRepoRoot) {
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: status.mainRepoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: normalizedCwd,
|
||||
isGit: true,
|
||||
currentBranch: status.currentBranch,
|
||||
remoteUrl: status.remoteUrl,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
};
|
||||
})
|
||||
.catch(
|
||||
(): ProjectCheckoutLitePayload => ({
|
||||
cwd: normalizedCwd,
|
||||
isGit: false,
|
||||
currentBranch: null,
|
||||
remoteUrl: null,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const projectKey = deriveProjectGroupingKey({
|
||||
cwd: normalizedCwd,
|
||||
remoteUrl: checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
|
||||
mainRepoRoot: checkout.mainRepoRoot,
|
||||
});
|
||||
|
||||
return {
|
||||
projectKey,
|
||||
projectName: deriveProjectGroupingName(projectKey),
|
||||
checkout,
|
||||
};
|
||||
}
|
||||
|
||||
172
packages/server/src/server/workspace-registry.test-helpers.ts
Normal file
172
packages/server/src/server/workspace-registry.test-helpers.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import {
|
||||
parsePersistedProjectRecords,
|
||||
parsePersistedWorkspaceRecords,
|
||||
type PersistedProjectRecord,
|
||||
type PersistedWorkspaceRecord,
|
||||
type ProjectRegistry,
|
||||
type WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
||||
|
||||
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
private readonly filePath: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly parseRecord: (record: unknown) => TRecord;
|
||||
private readonly parseRecords: (input: unknown) => TRecord[];
|
||||
private readonly getId: (record: TRecord) => number;
|
||||
private loaded = false;
|
||||
private readonly cache = new Map<string, TRecord>();
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: {
|
||||
filePath: string;
|
||||
logger: Logger;
|
||||
parseRecords: (input: unknown) => TRecord[];
|
||||
getId: (record: TRecord) => number;
|
||||
component: string;
|
||||
}) {
|
||||
this.filePath = options.filePath;
|
||||
this.parseRecords = options.parseRecords;
|
||||
this.parseRecord = (record) => options.parseRecords([record])[0]!;
|
||||
this.getId = options.getId;
|
||||
this.logger = options.logger.child({
|
||||
module: "workspace-registry",
|
||||
component: options.component,
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async existsOnDisk(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<TRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async get(id: number): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(String(id)) ?? null;
|
||||
}
|
||||
|
||||
async insert(record: Omit<TRecord, "id">): Promise<number> {
|
||||
await this.load();
|
||||
const nextId = Math.max(0, ...Array.from(this.cache.values(), (value) => this.getId(value))) + 1;
|
||||
const parsed = this.parseRecord({ ...record, id: nextId });
|
||||
this.cache.set(String(this.getId(parsed)), parsed);
|
||||
await this.enqueuePersist();
|
||||
return nextId;
|
||||
}
|
||||
|
||||
async upsert(record: TRecord): Promise<void> {
|
||||
await this.load();
|
||||
const parsed = this.parseRecord(record);
|
||||
this.cache.set(String(this.getId(parsed)), parsed);
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
async archive(id: number, archivedAt: string): Promise<void> {
|
||||
await this.load();
|
||||
const key = String(id);
|
||||
const existing = this.cache.get(key);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const next = this.parseRecord({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
});
|
||||
this.cache.set(key, next);
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
async remove(id: number): Promise<void> {
|
||||
await this.load();
|
||||
if (!this.cache.delete(String(id))) {
|
||||
return;
|
||||
}
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.clear();
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = this.parseRecords(JSON.parse(raw));
|
||||
for (const record of parsed) {
|
||||
this.cache.set(String(this.getId(record)), record);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
|
||||
}
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
const records = Array.from(this.cache.values());
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8");
|
||||
await fs.rename(tempPath, this.filePath);
|
||||
}
|
||||
|
||||
private async enqueuePersist(): Promise<void> {
|
||||
const nextPersist = this.persistQueue.then(() => this.persist());
|
||||
this.persistQueue = nextPersist.catch(() => {});
|
||||
await nextPersist;
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
parseRecords: parsePersistedProjectRecords,
|
||||
getId: (record) => record.id,
|
||||
component: "projects",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedWorkspaceRegistry
|
||||
extends FileBackedRegistry<PersistedWorkspaceRecord>
|
||||
implements WorkspaceRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
parseRecords: parsePersistedWorkspaceRecords,
|
||||
getId: (record) => record.id,
|
||||
component: "workspaces",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
FileBackedProjectRegistry,
|
||||
FileBackedWorkspaceRegistry,
|
||||
} from "./workspace-registry.test-helpers.js";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
describe("workspace registries", () => {
|
||||
@@ -36,71 +38,78 @@ describe("workspace registries", () => {
|
||||
|
||||
test("creates, updates, archives, deletes, and lists project records", async () => {
|
||||
await projectRegistry.initialize();
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
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(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
rootPath: "/tmp/repo",
|
||||
id: projectId,
|
||||
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-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await projectRegistry.archive("remote:github.com/acme/repo", "2026-03-03T00:00:00.000Z");
|
||||
await projectRegistry.archive(projectId, "2026-03-03T00:00:00.000Z");
|
||||
|
||||
const archived = await projectRegistry.get("remote:github.com/acme/repo");
|
||||
const archived = await projectRegistry.get(projectId);
|
||||
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
expect(await projectRegistry.list()).toHaveLength(1);
|
||||
|
||||
await projectRegistry.remove("remote:github.com/acme/repo");
|
||||
expect(await projectRegistry.get("remote:github.com/acme/repo")).toBeNull();
|
||||
await projectRegistry.remove(projectId);
|
||||
expect(await projectRegistry.get(projectId)).toBeNull();
|
||||
expect(await projectRegistry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("creates, updates, archives, deletes, and lists workspace records", async () => {
|
||||
await workspaceRegistry.initialize();
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
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(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "/tmp/repo",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
cwd: "/tmp/repo",
|
||||
kind: "local_checkout",
|
||||
id: workspaceId,
|
||||
projectId,
|
||||
directory: "/tmp/repo",
|
||||
kind: "checkout",
|
||||
displayName: "feature/workspace",
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await workspaceRegistry.archive("/tmp/repo", "2026-03-03T00:00:00.000Z");
|
||||
await workspaceRegistry.archive(workspaceId, "2026-03-03T00:00:00.000Z");
|
||||
|
||||
const archived = await workspaceRegistry.get("/tmp/repo");
|
||||
const archived = await workspaceRegistry.get(workspaceId);
|
||||
expect(archived?.displayName).toBe("feature/workspace");
|
||||
expect(archived?.archivedAt).toBe("2026-03-03T00:00:00.000Z");
|
||||
|
||||
await workspaceRegistry.remove("/tmp/repo");
|
||||
expect(await workspaceRegistry.get("/tmp/repo")).toBeNull();
|
||||
await workspaceRegistry.remove(workspaceId);
|
||||
expect(await workspaceRegistry.get(workspaceId)).toBeNull();
|
||||
expect(await workspaceRegistry.list()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PersistedProjectKind, PersistedWorkspaceKind } from "./workspace-registry-model.js";
|
||||
|
||||
const PersistedProjectRecordSchema = z.object({
|
||||
projectId: z.string(),
|
||||
rootPath: z.string(),
|
||||
kind: z.enum(["git", "non_git"]),
|
||||
id: z.number().int(),
|
||||
directory: z.string(),
|
||||
kind: z.enum(["git", "directory"]),
|
||||
displayName: z.string(),
|
||||
gitRemote: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
archivedAt: z.string().nullable(),
|
||||
});
|
||||
|
||||
const PersistedWorkspaceRecordSchema = z.object({
|
||||
workspaceId: z.string(),
|
||||
projectId: z.string(),
|
||||
cwd: z.string(),
|
||||
kind: z.enum(["local_checkout", "worktree", "directory"]),
|
||||
id: z.number().int(),
|
||||
projectId: z.number().int(),
|
||||
directory: z.string(),
|
||||
kind: z.enum(["checkout", "worktree"]),
|
||||
displayName: z.string(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
@@ -31,192 +25,58 @@ const PersistedWorkspaceRecordSchema = z.object({
|
||||
export type PersistedProjectRecord = z.infer<typeof PersistedProjectRecordSchema>;
|
||||
export type PersistedWorkspaceRecord = z.infer<typeof PersistedWorkspaceRecordSchema>;
|
||||
|
||||
export function parsePersistedProjectRecords(input: unknown): PersistedProjectRecord[] {
|
||||
return z.array(PersistedProjectRecordSchema).parse(input);
|
||||
}
|
||||
|
||||
export function parsePersistedWorkspaceRecords(input: unknown): PersistedWorkspaceRecord[] {
|
||||
return z.array(PersistedWorkspaceRecordSchema).parse(input);
|
||||
}
|
||||
|
||||
export interface ProjectRegistry {
|
||||
initialize(): Promise<void>;
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedProjectRecord[]>;
|
||||
get(projectId: string): Promise<PersistedProjectRecord | null>;
|
||||
get(id: number): Promise<PersistedProjectRecord | null>;
|
||||
insert(record: Omit<PersistedProjectRecord, "id">): Promise<number>;
|
||||
upsert(record: PersistedProjectRecord): Promise<void>;
|
||||
archive(projectId: string, archivedAt: string): Promise<void>;
|
||||
remove(projectId: string): Promise<void>;
|
||||
archive(id: number, archivedAt: string): Promise<void>;
|
||||
remove(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WorkspaceRegistry {
|
||||
initialize(): Promise<void>;
|
||||
existsOnDisk(): Promise<boolean>;
|
||||
list(): Promise<PersistedWorkspaceRecord[]>;
|
||||
get(workspaceId: string): Promise<PersistedWorkspaceRecord | null>;
|
||||
get(id: number): Promise<PersistedWorkspaceRecord | null>;
|
||||
insert(record: Omit<PersistedWorkspaceRecord, "id">): Promise<number>;
|
||||
upsert(record: PersistedWorkspaceRecord): Promise<void>;
|
||||
archive(workspaceId: string, archivedAt: string): Promise<void>;
|
||||
remove(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
type RegistryRecord = PersistedProjectRecord | PersistedWorkspaceRecord;
|
||||
|
||||
class FileBackedRegistry<TRecord extends RegistryRecord> {
|
||||
private readonly filePath: string;
|
||||
private readonly logger: Logger;
|
||||
private readonly schema: z.ZodSchema<TRecord>;
|
||||
private readonly getId: (record: TRecord) => string;
|
||||
private loaded = false;
|
||||
private readonly cache = new Map<string, TRecord>();
|
||||
private persistQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(options: {
|
||||
filePath: string;
|
||||
logger: Logger;
|
||||
schema: z.ZodSchema<TRecord>;
|
||||
getId: (record: TRecord) => string;
|
||||
component: string;
|
||||
}) {
|
||||
this.filePath = options.filePath;
|
||||
this.schema = options.schema;
|
||||
this.getId = options.getId;
|
||||
this.logger = options.logger.child({
|
||||
module: "workspace-registry",
|
||||
component: options.component,
|
||||
});
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async existsOnDisk(): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(this.filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async list(): Promise<TRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async get(id: string): Promise<TRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(id) ?? null;
|
||||
}
|
||||
|
||||
async upsert(record: TRecord): Promise<void> {
|
||||
await this.load();
|
||||
const parsed = this.schema.parse(record);
|
||||
this.cache.set(this.getId(parsed), parsed);
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
async archive(id: string, archivedAt: string): Promise<void> {
|
||||
await this.load();
|
||||
const existing = this.cache.get(id);
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const next = this.schema.parse({
|
||||
...existing,
|
||||
updatedAt: archivedAt,
|
||||
archivedAt,
|
||||
});
|
||||
this.cache.set(id, next);
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.load();
|
||||
if (!this.cache.delete(id)) {
|
||||
return;
|
||||
}
|
||||
await this.enqueuePersist();
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cache.clear();
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, "utf8");
|
||||
const parsed = z.array(this.schema).parse(JSON.parse(raw));
|
||||
for (const record of parsed) {
|
||||
this.cache.set(this.getId(record), record);
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== "ENOENT") {
|
||||
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load registry file");
|
||||
}
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
const records = Array.from(this.cache.values());
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
const tempPath = `${this.filePath}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
|
||||
await fs.writeFile(tempPath, JSON.stringify(records, null, 2), "utf8");
|
||||
await fs.rename(tempPath, this.filePath);
|
||||
}
|
||||
|
||||
private async enqueuePersist(): Promise<void> {
|
||||
const nextPersist = this.persistQueue.then(() => this.persist());
|
||||
this.persistQueue = nextPersist.catch(() => {});
|
||||
await nextPersist;
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedProjectRegistry
|
||||
extends FileBackedRegistry<PersistedProjectRecord>
|
||||
implements ProjectRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
schema: PersistedProjectRecordSchema,
|
||||
getId: (record) => record.projectId,
|
||||
component: "projects",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FileBackedWorkspaceRegistry
|
||||
extends FileBackedRegistry<PersistedWorkspaceRecord>
|
||||
implements WorkspaceRegistry
|
||||
{
|
||||
constructor(filePath: string, logger: Logger) {
|
||||
super({
|
||||
filePath,
|
||||
logger,
|
||||
schema: PersistedWorkspaceRecordSchema,
|
||||
getId: (record) => record.workspaceId,
|
||||
component: "workspaces",
|
||||
});
|
||||
}
|
||||
archive(id: number, archivedAt: string): Promise<void>;
|
||||
remove(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
export function createPersistedProjectRecord(input: {
|
||||
projectId: string;
|
||||
rootPath: string;
|
||||
kind: PersistedProjectKind;
|
||||
id: number;
|
||||
directory: string;
|
||||
kind: "git" | "directory";
|
||||
displayName: string;
|
||||
gitRemote?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
}): PersistedProjectRecord {
|
||||
return PersistedProjectRecordSchema.parse({
|
||||
...input,
|
||||
gitRemote: input.gitRemote ?? null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function createPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
projectId: string;
|
||||
cwd: string;
|
||||
kind: PersistedWorkspaceKind;
|
||||
id: number;
|
||||
projectId: number;
|
||||
directory: string;
|
||||
kind: "checkout" | "worktree";
|
||||
displayName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type WorkspaceDescriptorPayload,
|
||||
} from "./messages.js";
|
||||
import type {
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
@@ -77,26 +76,15 @@ type ArchivePaseoWorktreeDependencies = {
|
||||
};
|
||||
|
||||
type RegisterPendingWorktreeWorkspaceDependencies = {
|
||||
buildPersistedProjectRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedProjectRecord;
|
||||
buildPersistedWorkspaceRecord: (input: {
|
||||
workspaceId: string;
|
||||
placement: ProjectPlacementPayload;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}) => PersistedWorkspaceRecord;
|
||||
buildProjectPlacement: (cwd: string) => Promise<ProjectPlacementPayload>;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert">;
|
||||
findWorkspaceByDirectory: (directory: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
projectRegistry: Pick<ProjectRegistry, "get" | "upsert" | "insert" | "archive">;
|
||||
syncWorkspaceGitWatchTarget: (
|
||||
cwd: string,
|
||||
options: { isGit: boolean },
|
||||
) => Promise<void>;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert">;
|
||||
archiveProjectRecordIfEmpty: (projectId: string, archivedAt: string) => Promise<void>;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "upsert" | "insert" | "list">;
|
||||
archiveProjectRecordIfEmpty: (projectId: number, archivedAt: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
@@ -509,50 +497,50 @@ export async function registerPendingWorktreeWorkspace(
|
||||
branchName: string;
|
||||
},
|
||||
): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const workspaceDirectory = normalizePersistedWorkspaceId(options.worktreePath);
|
||||
const basePlacement = await dependencies.buildProjectPlacement(options.repoRoot);
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
};
|
||||
const projectId = Number(basePlacement.projectKey);
|
||||
if (!Number.isInteger(projectId)) {
|
||||
throw new Error(`Invalid project id for repo root ${options.repoRoot}`);
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const existingWorkspace = await dependencies.workspaceRegistry.get(workspaceId);
|
||||
const existingProject = await dependencies.projectRegistry.get(placement.projectKey);
|
||||
const nextProjectRecord = dependencies.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const nextWorkspaceRecord = dependencies.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const existingWorkspace = await dependencies.findWorkspaceByDirectory(workspaceDirectory);
|
||||
if (!existingWorkspace) {
|
||||
const workspaceId = await dependencies.workspaceRegistry.insert({
|
||||
projectId,
|
||||
directory: workspaceDirectory,
|
||||
displayName: options.branchName,
|
||||
kind: "worktree",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
});
|
||||
const workspace = await dependencies.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found after insert: ${workspaceId}`);
|
||||
}
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||
return workspace;
|
||||
}
|
||||
|
||||
await dependencies.projectRegistry.upsert(nextProjectRecord);
|
||||
await dependencies.workspaceRegistry.upsert(nextWorkspaceRecord);
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceId, {
|
||||
isGit: placement.checkout.isGit,
|
||||
await dependencies.workspaceRegistry.upsert({
|
||||
id: existingWorkspace.id,
|
||||
projectId,
|
||||
directory: workspaceDirectory,
|
||||
displayName: options.branchName,
|
||||
kind: "worktree",
|
||||
createdAt: existingWorkspace.createdAt,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
});
|
||||
await dependencies.syncWorkspaceGitWatchTarget(workspaceDirectory, { isGit: true });
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
if (!existingWorkspace.archivedAt && existingWorkspace.projectId !== projectId) {
|
||||
await dependencies.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now);
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord;
|
||||
return (await dependencies.workspaceRegistry.get(existingWorkspace.id))!;
|
||||
}
|
||||
|
||||
export async function handleCreatePaseoWorktreeRequest(
|
||||
@@ -585,6 +573,13 @@ export async function handleCreatePaseoWorktreeRequest(
|
||||
worktreePath,
|
||||
branchName: normalizedSlug,
|
||||
});
|
||||
await createAgentWorktree({
|
||||
cwd: repoRoot,
|
||||
branchName: normalizedSlug,
|
||||
baseBranch,
|
||||
worktreeSlug: normalizedSlug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
|
||||
dependencies.emit({
|
||||
type: "create_paseo_worktree_response",
|
||||
@@ -634,14 +629,6 @@ export async function createPaseoWorktreeInBackground(
|
||||
let setupTerminalId: string | null = null;
|
||||
|
||||
try {
|
||||
await createAgentWorktree({
|
||||
cwd: options.repoRoot,
|
||||
branchName: options.slug,
|
||||
baseBranch: options.baseBranch,
|
||||
worktreeSlug: options.slug,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
|
||||
if (setupCommands.length > 0 && dependencies.terminalManager) {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AgentStreamMessageSchema,
|
||||
FetchAgentTimelineRequestMessageSchema,
|
||||
FetchAgentTimelineResponseMessageSchema,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
@@ -17,14 +18,8 @@ describe("shared messages stream parsing", () => {
|
||||
agentId: "agent_live",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 2, nextSeq: 3 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 2 },
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
@@ -32,10 +27,7 @@ describe("shared messages stream parsing", () => {
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 2,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
|
||||
collapsed: ["assistant_merge"],
|
||||
seq: 2,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
@@ -46,6 +38,51 @@ describe("shared messages stream parsing", () => {
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
|
||||
});
|
||||
|
||||
it("rejects removed fetch timeline request baggage at the parser boundary", () => {
|
||||
const parsed = FetchAgentTimelineRequestMessageSchema.safeParse({
|
||||
type: "fetch_agent_timeline_request",
|
||||
agentId: "agent_live",
|
||||
requestId: "req-legacy",
|
||||
direction: "after",
|
||||
cursor: {
|
||||
seq: 12,
|
||||
epoch: "legacy-epoch",
|
||||
},
|
||||
projection: "canonical",
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects removed fetch timeline response baggage at the parser boundary", () => {
|
||||
const parsed = FetchAgentTimelineResponseMessageSchema.safeParse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: "req-1",
|
||||
agentId: "agent_live",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
reset: false,
|
||||
startCursor: { seq: 1 },
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: { type: "assistant_message", text: "hello" },
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
seq: 2,
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
|
||||
const shutdownParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "shutdown_server_request",
|
||||
@@ -70,6 +107,7 @@ describe("shared messages stream parsing", () => {
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
seq: 12,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -97,6 +135,27 @@ describe("shared messages stream parsing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects removed agent_stream baggage at the parser boundary", () => {
|
||||
const parsed = AgentStreamMessageSchema.safeParse({
|
||||
type: "agent_stream",
|
||||
payload: {
|
||||
agentId: "agent_live",
|
||||
timestamp: "2026-02-08T20:10:00.000Z",
|
||||
epoch: "legacy-epoch",
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "hello",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("parses representative sub_agent tool_call event", () => {
|
||||
const parsed = AgentStreamMessageSchema.parse({
|
||||
type: "agent_stream",
|
||||
|
||||
@@ -95,6 +95,7 @@ const AgentCapabilityFlagsSchema: z.ZodType<AgentCapabilityFlags> = z.object({
|
||||
supportsMcpServers: z.boolean(),
|
||||
supportsReasoningStream: z.boolean(),
|
||||
supportsToolInvocations: z.boolean(),
|
||||
supportsTerminalMode: z.boolean(),
|
||||
});
|
||||
|
||||
const AgentUsageSchema: z.ZodType<AgentUsage> = z.object({
|
||||
@@ -132,6 +133,7 @@ const McpServerConfigSchema = z.discriminatedUnion("type", [
|
||||
const AgentSessionConfigSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string(),
|
||||
terminal: z.boolean().optional(),
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
@@ -453,10 +455,19 @@ const AgentRuntimeInfoSchema: z.ZodType<AgentRuntimeInfo> = z.object({
|
||||
extra: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const TerminalExitDetailsSchema = z.object({
|
||||
command: z.string(),
|
||||
message: z.string(),
|
||||
exitCode: z.number().nullable(),
|
||||
signal: z.number().nullable(),
|
||||
outputLines: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const AgentSnapshotPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
cwd: z.string(),
|
||||
terminal: z.boolean().optional(),
|
||||
model: z.string().nullable(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||
@@ -472,6 +483,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
runtimeInfo: AgentRuntimeInfoSchema.optional(),
|
||||
lastUsage: AgentUsageSchema.optional(),
|
||||
lastError: z.string().optional(),
|
||||
terminalExit: TerminalExitDetailsSchema.optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string()).default({}),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
@@ -605,7 +617,7 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
|
||||
filter: z
|
||||
.object({
|
||||
query: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
projectId: z.number().int().optional(),
|
||||
idPrefix: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@@ -704,6 +716,7 @@ export type GitSetupOptions = z.infer<typeof GitSetupOptionsSchema>;
|
||||
export const CreateAgentRequestMessageSchema = z.object({
|
||||
type: z.literal("create_agent_request"),
|
||||
config: AgentSessionConfigSchema,
|
||||
workspaceId: z.number().int().optional(),
|
||||
worktreeName: z.string().optional(),
|
||||
initialPrompt: z.string().optional(),
|
||||
clientMessageId: z.string().optional(),
|
||||
@@ -762,22 +775,23 @@ export const ShutdownServerRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const AgentTimelineCursorSchema = z.object({
|
||||
epoch: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
});
|
||||
export const AgentTimelineCursorSchema = z
|
||||
.object({
|
||||
seq: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const FetchAgentTimelineRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_timeline_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
direction: z.enum(["tail", "before", "after"]).optional(),
|
||||
cursor: AgentTimelineCursorSchema.optional(),
|
||||
// 0 means "all matching rows for this query window".
|
||||
limit: z.number().int().nonnegative().optional(),
|
||||
// Default should be projected for app timeline loading.
|
||||
projection: z.enum(["projected", "canonical"]).optional(),
|
||||
});
|
||||
export const FetchAgentTimelineRequestMessageSchema = z
|
||||
.object({
|
||||
type: z.literal("fetch_agent_timeline_request"),
|
||||
agentId: z.string(),
|
||||
requestId: z.string(),
|
||||
direction: z.enum(["tail", "before", "after"]).optional(),
|
||||
cursor: AgentTimelineCursorSchema.optional(),
|
||||
// 0 means "all matching rows for this query window".
|
||||
limit: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SetAgentModeRequestMessageSchema = z.object({
|
||||
type: z.literal("set_agent_mode_request"),
|
||||
@@ -998,7 +1012,7 @@ export const OpenProjectRequestSchema = z.object({
|
||||
|
||||
export const ArchiveWorkspaceRequestSchema = z.object({
|
||||
type: z.literal("archive_workspace_request"),
|
||||
workspaceId: z.string(),
|
||||
workspaceId: z.number().int(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -1141,6 +1155,9 @@ export const CreateTerminalRequestSchema = z.object({
|
||||
type: z.literal("create_terminal_request"),
|
||||
cwd: z.string(),
|
||||
name: z.string().optional(),
|
||||
agentId: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.string()).optional(),
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
@@ -1580,12 +1597,13 @@ export const ProjectPlacementPayloadSchema = z.object({
|
||||
});
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
id: z.number().int(),
|
||||
projectId: z.number().int(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
projectKind: z.enum(["git", "non_git"]),
|
||||
workspaceKind: z.enum(["local_checkout", "worktree", "directory"]),
|
||||
workspaceDirectory: z.string(),
|
||||
projectKind: z.enum(["git", "directory"]),
|
||||
workspaceKind: z.enum(["checkout", "worktree"]),
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
@@ -1613,17 +1631,20 @@ export const AgentUpdateMessageSchema = z.object({
|
||||
]),
|
||||
});
|
||||
|
||||
export const AgentStreamMessageSchema = z.object({
|
||||
type: z.literal("agent_stream"),
|
||||
payload: z.object({
|
||||
agentId: z.string(),
|
||||
event: AgentStreamEventPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
// Present for timeline events. Maps 1:1 to canonical in-memory timeline rows.
|
||||
seq: z.number().int().nonnegative().optional(),
|
||||
epoch: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
export const AgentStreamMessageSchema = z
|
||||
.object({
|
||||
type: z.literal("agent_stream"),
|
||||
payload: z
|
||||
.object({
|
||||
agentId: z.string(),
|
||||
event: AgentStreamEventPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
// Present only for committed timeline events.
|
||||
seq: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AgentStatusMessageSchema = z.object({
|
||||
type: z.literal("agent_status"),
|
||||
@@ -1683,7 +1704,7 @@ export const WorkspaceUpdateMessageSchema = z.object({
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("remove"),
|
||||
id: z.string(),
|
||||
id: z.number().int(),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
@@ -1701,7 +1722,7 @@ export const ArchiveWorkspaceResponseMessageSchema = z.object({
|
||||
type: z.literal("archive_workspace_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
workspaceId: z.number().int(),
|
||||
archivedAt: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
@@ -1717,46 +1738,34 @@ export const FetchAgentResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
const AgentTimelineSeqRangeSchema = z.object({
|
||||
startSeq: z.number().int().nonnegative(),
|
||||
endSeq: z.number().int().nonnegative(),
|
||||
});
|
||||
export const AgentTimelineEntryPayloadSchema = z
|
||||
.object({
|
||||
provider: AgentProviderSchema,
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
seq: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const AgentTimelineEntryPayloadSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
item: AgentTimelineItemPayloadSchema,
|
||||
timestamp: z.string(),
|
||||
seqStart: z.number().int().nonnegative(),
|
||||
seqEnd: z.number().int().nonnegative(),
|
||||
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
|
||||
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
|
||||
});
|
||||
|
||||
export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_timeline_response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||
direction: z.enum(["tail", "before", "after"]),
|
||||
projection: z.enum(["projected", "canonical"]),
|
||||
epoch: z.string(),
|
||||
reset: z.boolean(),
|
||||
staleCursor: z.boolean(),
|
||||
gap: z.boolean(),
|
||||
window: z.object({
|
||||
minSeq: z.number().int().nonnegative(),
|
||||
maxSeq: z.number().int().nonnegative(),
|
||||
nextSeq: z.number().int().nonnegative(),
|
||||
}),
|
||||
startCursor: AgentTimelineCursorSchema.nullable(),
|
||||
endCursor: AgentTimelineCursorSchema.nullable(),
|
||||
hasOlder: z.boolean(),
|
||||
hasNewer: z.boolean(),
|
||||
entries: z.array(AgentTimelineEntryPayloadSchema),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
});
|
||||
export const FetchAgentTimelineResponseMessageSchema = z
|
||||
.object({
|
||||
type: z.literal("fetch_agent_timeline_response"),
|
||||
payload: z
|
||||
.object({
|
||||
requestId: z.string(),
|
||||
agentId: z.string(),
|
||||
agent: AgentSnapshotPayloadSchema.nullable(),
|
||||
direction: z.enum(["tail", "before", "after"]),
|
||||
startSeq: z.number().int().nonnegative().nullable(),
|
||||
endSeq: z.number().int().nonnegative().nullable(),
|
||||
hasOlder: z.boolean(),
|
||||
hasNewer: z.boolean(),
|
||||
entries: z.array(AgentTimelineEntryPayloadSchema),
|
||||
error: z.string().nullable(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const SendAgentMessageResponseMessageSchema = z.object({
|
||||
type: z.literal("send_agent_message_response"),
|
||||
@@ -2152,6 +2161,7 @@ const TerminalInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
cwd: z.string(),
|
||||
title: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TerminalCellSchema = z
|
||||
@@ -2189,6 +2199,7 @@ export const TerminalStateSchema = z
|
||||
grid: z.array(z.array(TerminalCellSchema)),
|
||||
scrollback: z.array(z.array(TerminalCellSchema)),
|
||||
cursor: TerminalCursorSchema,
|
||||
title: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("workspace message schemas", () => {
|
||||
requestId: "req-1",
|
||||
filter: {
|
||||
query: "repo",
|
||||
projectId: "remote:github.com/acme/repo",
|
||||
projectId: 12,
|
||||
idPrefix: "/Users/me",
|
||||
},
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
@@ -35,12 +35,12 @@ describe("workspace message schemas", () => {
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "/repo",
|
||||
projectId: "/repo",
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
name: "",
|
||||
status: "not-a-bucket",
|
||||
activityAt: null,
|
||||
|
||||
17
packages/server/src/terminal/shell-integration/zsh/.zshenv
Normal file
17
packages/server/src/terminal/shell-integration/zsh/.zshenv
Normal file
@@ -0,0 +1,17 @@
|
||||
typeset -g PASEO_SHELL_INTEGRATION_DIR="${${(%):-%N}:A:h}"
|
||||
|
||||
if [[ -n "${PASEO_ZSH_ZDOTDIR-}" ]]; then
|
||||
export ZDOTDIR="${PASEO_ZSH_ZDOTDIR}"
|
||||
else
|
||||
unset ZDOTDIR
|
||||
fi
|
||||
|
||||
if [[ -n "${ZDOTDIR-}" ]]; then
|
||||
if [[ -f "${ZDOTDIR}/.zshenv" ]]; then
|
||||
source "${ZDOTDIR}/.zshenv"
|
||||
fi
|
||||
elif [[ -f "${HOME}/.zshenv" ]]; then
|
||||
source "${HOME}/.zshenv"
|
||||
fi
|
||||
|
||||
source "${PASEO_SHELL_INTEGRATION_DIR}/paseo-integration.zsh"
|
||||
@@ -0,0 +1,17 @@
|
||||
if [[ -n "${_PASEO_ZSH_INTEGRATION_LOADED-}" ]]; then
|
||||
return
|
||||
fi
|
||||
typeset -g _PASEO_ZSH_INTEGRATION_LOADED=1
|
||||
|
||||
autoload -Uz add-zsh-hook
|
||||
|
||||
function _paseo_precmd() {
|
||||
printf '\e]2;%s\a' "${PWD/#$HOME/~}"
|
||||
}
|
||||
|
||||
function _paseo_preexec() {
|
||||
printf '\e]2;%s\a' "$1"
|
||||
}
|
||||
|
||||
add-zsh-hook precmd _paseo_precmd
|
||||
add-zsh-hook preexec _paseo_preexec
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { createTerminalManager, type TerminalManager } from "./terminal-manager.js";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
@@ -301,11 +301,14 @@ describe("TerminalManager", () => {
|
||||
describe("subscribeTerminalsChanged", () => {
|
||||
it("emits cwd snapshots when terminals are created", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<{ cwd: string; terminalNames: string[] }> = [];
|
||||
const snapshots: Array<{ cwd: string; terminals: Array<{ name: string; title?: string }> }> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push({
|
||||
cwd: input.cwd,
|
||||
terminalNames: input.terminals.map((terminal) => terminal.name),
|
||||
terminals: input.terminals.map((terminal) => ({
|
||||
name: terminal.name,
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,16 +317,111 @@ describe("TerminalManager", () => {
|
||||
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
terminalNames: ["Terminal 1"],
|
||||
terminals: [{ name: "Terminal 1" }],
|
||||
});
|
||||
expect(snapshots).toContainEqual({
|
||||
cwd: "/tmp",
|
||||
terminalNames: ["Terminal 1", "Dev Server"],
|
||||
terminals: [{ name: "Terminal 1" }, { name: "Dev Server" }],
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it(
|
||||
"emits updated terminal titles after debounced title changes",
|
||||
async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(
|
||||
input.terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
session.send({ type: "input", data: "printf '\\033]0;Logs\\007'\r" });
|
||||
|
||||
await waitForCondition(
|
||||
() =>
|
||||
snapshots.some((snapshot) =>
|
||||
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Logs"),
|
||||
),
|
||||
10000,
|
||||
);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
},
|
||||
10000,
|
||||
);
|
||||
|
||||
it("forwards bound terminal titles through the agent bridge without changing standalone lists", async () => {
|
||||
await withShell("/bin/sh", async () => {
|
||||
const onAgentBoundTerminalTitleChange = vi.fn();
|
||||
manager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: () => "agent-1",
|
||||
onAgentBoundTerminalTitleChange,
|
||||
});
|
||||
|
||||
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
|
||||
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
|
||||
snapshots.push(
|
||||
input.terminals.map((terminal) => ({
|
||||
id: terminal.id,
|
||||
...(terminal.title ? { title: terminal.title } : {}),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
const session = await manager.createTerminal({ cwd: "/tmp" });
|
||||
session.send({ type: "input", data: "printf '\\033]0;Agent Shell\\007'\r" });
|
||||
|
||||
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
|
||||
|
||||
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
title: "Agent Shell",
|
||||
});
|
||||
expect(
|
||||
snapshots.some((snapshot) =>
|
||||
snapshot.some((terminal) => terminal.id === session.id && terminal.title === "Agent Shell"),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards initial titles for agent-bound terminals created with command args", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-manager-title-script-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "npm-cli.js");
|
||||
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||
|
||||
const onAgentBoundTerminalTitleChange = vi.fn();
|
||||
manager = createTerminalManager({
|
||||
resolveAgentIdForTerminal: () => "agent-1",
|
||||
onAgentBoundTerminalTitleChange,
|
||||
});
|
||||
|
||||
await manager.createTerminal({
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
});
|
||||
|
||||
await waitForCondition(() => onAgentBoundTerminalTitleChange.mock.calls.length > 0, 10000);
|
||||
|
||||
expect(onAgentBoundTerminalTitleChange).toHaveBeenCalledWith({
|
||||
agentId: "agent-1",
|
||||
title: "npm run dev",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits empty snapshot when last terminal is removed", async () => {
|
||||
manager = createTerminalManager();
|
||||
const snapshots: Array<{ cwd: string; terminalCount: number }> = [];
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface TerminalListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TerminalsChangedEvent {
|
||||
@@ -17,9 +18,12 @@ export type TerminalsChangedListener = (input: TerminalsChangedEvent) => void;
|
||||
export interface TerminalManager {
|
||||
getTerminals(cwd: string): Promise<TerminalSession[]>;
|
||||
createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
}): Promise<TerminalSession>;
|
||||
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
|
||||
getTerminal(id: string): TerminalSession | undefined;
|
||||
@@ -29,10 +33,16 @@ export interface TerminalManager {
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void;
|
||||
}
|
||||
|
||||
export function createTerminalManager(): TerminalManager {
|
||||
type AgentBoundTerminalTitleHandler = (input: { agentId: string; title: string }) => Promise<void> | void;
|
||||
|
||||
export function createTerminalManager(options?: {
|
||||
resolveAgentIdForTerminal?: (terminalId: string) => string | null;
|
||||
onAgentBoundTerminalTitleChange?: AgentBoundTerminalTitleHandler;
|
||||
}): TerminalManager {
|
||||
const terminalsByCwd = new Map<string, TerminalSession[]>();
|
||||
const terminalsById = new Map<string, TerminalSession>();
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalTitleUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
|
||||
@@ -53,6 +63,11 @@ export function createTerminalManager(): TerminalManager {
|
||||
unsubscribeExit();
|
||||
terminalExitUnsubscribeById.delete(id);
|
||||
}
|
||||
const unsubscribeTitle = terminalTitleUnsubscribeById.get(id);
|
||||
if (unsubscribeTitle) {
|
||||
unsubscribeTitle();
|
||||
terminalTitleUnsubscribeById.delete(id);
|
||||
}
|
||||
|
||||
terminalsById.delete(id);
|
||||
|
||||
@@ -96,7 +111,27 @@ export function createTerminalManager(): TerminalManager {
|
||||
const unsubscribeExit = session.onExit(() => {
|
||||
removeSessionById(session.id, { kill: false });
|
||||
});
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
emitTerminalsChanged({ cwd: session.cwd });
|
||||
const normalizedTitle = title?.trim();
|
||||
if (!normalizedTitle) {
|
||||
return;
|
||||
}
|
||||
const agentId = options?.resolveAgentIdForTerminal?.(session.id) ?? null;
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(
|
||||
options?.onAgentBoundTerminalTitleChange?.({
|
||||
agentId,
|
||||
title: normalizedTitle,
|
||||
}),
|
||||
).catch(() => {
|
||||
// no-op
|
||||
});
|
||||
});
|
||||
terminalExitUnsubscribeById.set(session.id, unsubscribeExit);
|
||||
terminalTitleUnsubscribeById.set(session.id, unsubscribeTitle);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -105,6 +140,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
id: input.session.id,
|
||||
name: input.session.name,
|
||||
cwd: input.session.cwd,
|
||||
title: input.session.getTitle(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,9 +174,12 @@ export function createTerminalManager(): TerminalManager {
|
||||
},
|
||||
|
||||
async createTerminal(options: {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
name?: string;
|
||||
env?: Record<string, string>;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
}): Promise<TerminalSession> {
|
||||
assertAbsolutePath(options.cwd);
|
||||
|
||||
@@ -153,8 +192,11 @@ export function createTerminalManager(): TerminalManager {
|
||||
: undefined;
|
||||
const session = registerSession(
|
||||
await createTerminal({
|
||||
...(options.id ? { id: options.id } : {}),
|
||||
cwd: options.cwd,
|
||||
name: options.name ?? defaultName,
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
...(options.args ? { args: options.args } : {}),
|
||||
...(mergedEnv ? { env: mergedEnv } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import {
|
||||
buildTerminalEnvironment,
|
||||
createTerminal,
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
|
||||
resolveDefaultTerminalShell,
|
||||
humanizeProcessTitle,
|
||||
normalizeProcessTitle,
|
||||
resolveZshShellIntegrationDir,
|
||||
type TerminalSession,
|
||||
} from "./terminal.js";
|
||||
import { chmodSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
@@ -71,6 +75,23 @@ async function waitForState(
|
||||
throw new Error("Timeout waiting for terminal state predicate to match");
|
||||
}
|
||||
|
||||
async function waitForTitle(
|
||||
session: TerminalSession,
|
||||
predicate: (title: string | undefined) => boolean,
|
||||
timeoutMs = 5000,
|
||||
): Promise<string | undefined> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const title = session.getTitle();
|
||||
if (predicate(title)) {
|
||||
return title;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
throw new Error("Timeout waiting for terminal title predicate to match");
|
||||
}
|
||||
|
||||
describe("Terminal", () => {
|
||||
const sessions: TerminalSession[] = [];
|
||||
const temporaryDirs: string[] = [];
|
||||
@@ -94,6 +115,30 @@ describe("Terminal", () => {
|
||||
}
|
||||
|
||||
describe("createTerminal", () => {
|
||||
it("keeps full process titles while stripping path prefixes", () => {
|
||||
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
|
||||
expect(normalizeProcessTitle("/opt/homebrew/bin/node /tmp/work/npm-cli.js run dev")).toBe(
|
||||
"node npm-cli.js run dev",
|
||||
);
|
||||
expect(normalizeProcessTitle("")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("humanizes interpreter-backed package manager commands", () => {
|
||||
expect(
|
||||
humanizeProcessTitle("/usr/local/bin/node /opt/homebrew/lib/node_modules/npm/bin/npm-cli.js run dev"),
|
||||
).toBe("npm run dev");
|
||||
expect(
|
||||
humanizeProcessTitle("/usr/bin/env FOO=bar /opt/homebrew/bin/node /tmp/npm-cli.js test"),
|
||||
).toBe("npm test");
|
||||
});
|
||||
|
||||
it("drops common interpreter prefixes for direct scripts", () => {
|
||||
expect(humanizeProcessTitle("/usr/bin/python3 /tmp/server.py --port 3000")).toBe(
|
||||
"server.py --port 3000",
|
||||
);
|
||||
expect(humanizeProcessTitle("/bin/bash /tmp/dev.sh")).toBe("dev.sh");
|
||||
});
|
||||
|
||||
it("ensures darwin prebuild spawn-helper is executable", () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-node-pty-helper-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
@@ -140,6 +185,20 @@ describe("Terminal", () => {
|
||||
expect(session.cwd).toBe("/tmp");
|
||||
});
|
||||
|
||||
it("sets zsh wrapper env when spawning zsh", () => {
|
||||
const resolvedEnv = buildTerminalEnvironment({
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
HOME: "/tmp/paseo-home",
|
||||
ZDOTDIR: "/tmp/paseo-zdotdir",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolvedEnv.TERM).toBe("xterm-256color");
|
||||
expect(resolvedEnv.PASEO_ZSH_ZDOTDIR).toBe("/tmp/paseo-zdotdir");
|
||||
expect(resolvedEnv.ZDOTDIR).toBe(resolveZshShellIntegrationDir());
|
||||
});
|
||||
|
||||
it("uses custom name when provided", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
@@ -192,6 +251,28 @@ describe("Terminal", () => {
|
||||
expect(state.rows).toBe(40);
|
||||
expect(state.cols).toBe(120);
|
||||
});
|
||||
|
||||
it("captures exit diagnostics from the terminal buffer", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
command: "/bin/sh",
|
||||
args: ["-lc", "printf 'launch failed\\ncommand missing\\n'; exit 127"],
|
||||
}),
|
||||
);
|
||||
|
||||
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
|
||||
(resolve) => {
|
||||
session.onExit((info) => resolve(info));
|
||||
},
|
||||
);
|
||||
|
||||
expect(exitInfo.exitCode).toBe(127);
|
||||
expect(exitInfo.signal).toBeNull();
|
||||
// lastOutputLines may be empty if the process exits before xterm processes the data write
|
||||
expect(Array.isArray(exitInfo.lastOutputLines)).toBe(true);
|
||||
expect(session.getExitInfo()).toEqual(exitInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe("send input", () => {
|
||||
@@ -268,6 +349,154 @@ describe("Terminal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("terminal title", () => {
|
||||
it("restores the user's ZDOTDIR through the zsh wrapper", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||
mkdirSync(realZdotdir, { recursive: true });
|
||||
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: homeDir,
|
||||
command: "/bin/zsh",
|
||||
args: ["-c", "printf '%s\\n%s\\n' \"${ZDOTDIR-}\" \"${PASEO_TEST_REAL_ZDOTDIR-}\""],
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: realZdotdir,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
|
||||
(resolve) => {
|
||||
session.onExit((info) => resolve(info));
|
||||
},
|
||||
);
|
||||
|
||||
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
|
||||
});
|
||||
|
||||
it("emits the initial title from command args to title listeners", async () => {
|
||||
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
|
||||
temporaryDirs.push(packageRoot);
|
||||
const scriptPath = join(packageRoot, "npm-cli.js");
|
||||
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: packageRoot,
|
||||
command: process.execPath,
|
||||
args: [scriptPath, "run", "dev"],
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForTitle(session, (title) => title === "npm run dev");
|
||||
await waitForState(session, (state) => state.title === "npm run dev");
|
||||
|
||||
expect(seenTitles).toContain("npm run dev");
|
||||
expect(session.getTitle()).toBe("npm run dev");
|
||||
expect(session.getState().title).toBe("npm run dev");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("emits OSC title updates to title listeners", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "Build Log");
|
||||
|
||||
expect(seenTitles).toContain("Build Log");
|
||||
expect(session.getTitle()).toBe("Build Log");
|
||||
expect(session.getState().title).toBe("Build Log");
|
||||
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("debounces rapid title changes and emits only the final title", async () => {
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: "/tmp",
|
||||
shell: "/bin/sh",
|
||||
env: { PS1: "$ " },
|
||||
}),
|
||||
);
|
||||
const seenTitles: Array<string | undefined> = [];
|
||||
const seenMessages: Array<string | undefined> = [];
|
||||
const unsubscribeTitle = session.onTitleChange((title) => {
|
||||
seenTitles.push(title);
|
||||
});
|
||||
const unsubscribeMessages = session.subscribe((message) => {
|
||||
if (message.type === "titleChange") {
|
||||
seenMessages.push(message.title);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
session.send({
|
||||
type: "input",
|
||||
data:
|
||||
"printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
|
||||
});
|
||||
|
||||
await waitForTitle(session, (title) => title === "Final");
|
||||
|
||||
expect(seenTitles).toEqual(["Final"]);
|
||||
expect(seenMessages).toEqual(["Final"]);
|
||||
|
||||
unsubscribeMessages();
|
||||
unsubscribeTitle();
|
||||
});
|
||||
|
||||
it("emits zsh shell integration titles for commands and prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
|
||||
temporaryDirs.push(homeDir);
|
||||
const realZdotdir = join(homeDir, ".config", "zsh");
|
||||
const workingDir = join(homeDir, "dev", "faro");
|
||||
mkdirSync(realZdotdir, { recursive: true });
|
||||
mkdirSync(workingDir, { recursive: true });
|
||||
writeFileSync(join(realZdotdir, ".zshenv"), "");
|
||||
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
|
||||
|
||||
const session = trackSession(
|
||||
await createTerminal({
|
||||
cwd: workingDir,
|
||||
shell: "/bin/zsh",
|
||||
env: {
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: realZdotdir,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await waitForLines(session, ["$"]);
|
||||
await waitForTitle(session, (title) => title === "~/dev/faro");
|
||||
|
||||
session.send({ type: "input", data: "sleep 1\r" });
|
||||
|
||||
await waitForTitle(session, (title) => title === "sleep 1");
|
||||
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("colors", () => {
|
||||
it("captures ANSI 16 color codes (mode 1)", async () => {
|
||||
const session = trackSession(
|
||||
|
||||
@@ -2,14 +2,24 @@ import * as pty from "node-pty";
|
||||
import xterm, { type Terminal as TerminalType } from "@xterm/headless";
|
||||
import { randomUUID } from "crypto";
|
||||
import { chmodSync, existsSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const require = createRequire(import.meta.url);
|
||||
let nodePtySpawnHelperChecked = false;
|
||||
const TERMINAL_TITLE_DEBOUNCE_MS = 150;
|
||||
const TERMINAL_EXIT_OUTPUT_LINE_LIMIT = 12;
|
||||
const TERMINAL_EXIT_OUTPUT_CHAR_LIMIT = 16000;
|
||||
|
||||
export interface TerminalExitInfo {
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
lastOutputLines: string[];
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "input"; data: string }
|
||||
@@ -18,7 +28,8 @@ export type ClientMessage =
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: "output"; data: string }
|
||||
| { type: "snapshot"; state: TerminalState };
|
||||
| { type: "snapshot"; state: TerminalState }
|
||||
| { type: "titleChange"; title?: string };
|
||||
|
||||
export interface TerminalSession {
|
||||
id: string;
|
||||
@@ -26,19 +37,30 @@ export interface TerminalSession {
|
||||
cwd: string;
|
||||
send(msg: ClientMessage): void;
|
||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||
onExit(listener: () => void): () => void;
|
||||
onExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onTitleChange(listener: (title?: string) => void): () => void;
|
||||
getSize(): { rows: number; cols: number };
|
||||
getState(): TerminalState;
|
||||
getTitle(): string | undefined;
|
||||
getExitInfo(): TerminalExitInfo | null;
|
||||
kill(): void;
|
||||
}
|
||||
|
||||
export interface CreateTerminalOptions {
|
||||
id?: string;
|
||||
cwd: string;
|
||||
shell?: string;
|
||||
env?: Record<string, string>;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
name?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
}
|
||||
|
||||
interface BuildTerminalEnvironmentInput {
|
||||
shell: string;
|
||||
env: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CaptureTerminalLinesOptions {
|
||||
@@ -135,6 +157,29 @@ export function resolveDefaultTerminalShell(
|
||||
return env.SHELL || "/bin/sh";
|
||||
}
|
||||
|
||||
export function resolveZshShellIntegrationDir(): string {
|
||||
return fileURLToPath(new URL("./shell-integration/zsh", import.meta.url));
|
||||
}
|
||||
|
||||
export function buildTerminalEnvironment(input: BuildTerminalEnvironmentInput): Record<string, string> {
|
||||
const baseEnv: Record<string, string> = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
TERM: "xterm-256color",
|
||||
};
|
||||
|
||||
if (basename(input.shell) !== "zsh") {
|
||||
return baseEnv;
|
||||
}
|
||||
|
||||
const originalZdotdir = baseEnv.ZDOTDIR ?? "";
|
||||
return {
|
||||
...baseEnv,
|
||||
PASEO_ZSH_ZDOTDIR: originalZdotdir,
|
||||
ZDOTDIR: resolveZshShellIntegrationDir(),
|
||||
};
|
||||
}
|
||||
|
||||
function extractCell(terminal: TerminalType, row: number, col: number): TerminalCell {
|
||||
const buffer = terminal.buffer.active;
|
||||
const line = buffer.getLine(row);
|
||||
@@ -258,6 +303,157 @@ function extractCursorState(terminal: TerminalType): TerminalState["cursor"] {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProcessToken(token: string): string {
|
||||
if (token.length === 0) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const quote =
|
||||
token.startsWith('"') && token.endsWith('"')
|
||||
? '"'
|
||||
: token.startsWith("'") && token.endsWith("'")
|
||||
? "'"
|
||||
: "";
|
||||
const rawToken = quote ? token.slice(1, -1) : token;
|
||||
if (rawToken.length === 0) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const assignmentMatch = rawToken.match(/^([A-Za-z_][A-Za-z0-9_]*=)(.+)$/);
|
||||
const prefix = assignmentMatch ? assignmentMatch[1] : "";
|
||||
const value = assignmentMatch ? assignmentMatch[2] : rawToken;
|
||||
if (!value.includes("/")) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const normalized = `${prefix}${basename(value)}`;
|
||||
return quote ? `${quote}${normalized}${quote}` : normalized;
|
||||
}
|
||||
|
||||
export function normalizeProcessTitle(processTitle: string): string | undefined {
|
||||
const trimmed = processTitle.trim().replace(/\s+/g, " ");
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = trimmed
|
||||
.split(" ")
|
||||
.map((token) => normalizeProcessToken(token))
|
||||
.join(" ")
|
||||
.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
const PROCESS_INTERPRETERS = new Set([
|
||||
"bash",
|
||||
"bun",
|
||||
"deno",
|
||||
"node",
|
||||
"nodejs",
|
||||
"python",
|
||||
"python3",
|
||||
"ruby",
|
||||
"sh",
|
||||
"tsx",
|
||||
"zsh",
|
||||
]);
|
||||
|
||||
const PACKAGE_MANAGER_SCRIPT_NAMES = new Map<string, string>([
|
||||
["bun.js", "bun"],
|
||||
["npm-cli.js", "npm"],
|
||||
["npx-cli.js", "npx"],
|
||||
["pnpm.cjs", "pnpm"],
|
||||
["pnpm.js", "pnpm"],
|
||||
["yarn.cjs", "yarn"],
|
||||
["yarn.js", "yarn"],
|
||||
]);
|
||||
|
||||
export function humanizeProcessTitle(processTitle: string): string | undefined {
|
||||
const normalized = normalizeProcessTitle(processTitle);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokens = normalized.split(" ").filter(Boolean);
|
||||
if (tokens.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
while (tokens[0] === "env") {
|
||||
tokens.shift();
|
||||
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) {
|
||||
tokens.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const second = tokens[1];
|
||||
if (PROCESS_INTERPRETERS.has(first) && second) {
|
||||
const packageManager = PACKAGE_MANAGER_SCRIPT_NAMES.get(second);
|
||||
if (packageManager) {
|
||||
return [packageManager, ...tokens.slice(2)].join(" ").trim() || packageManager;
|
||||
}
|
||||
|
||||
if (!second.startsWith("-")) {
|
||||
return [second, ...tokens.slice(2)].join(" ").trim();
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function extractLastOutputLines(terminal: TerminalType, limit: number): string[] {
|
||||
const buffer = terminal.buffer.active;
|
||||
const mergedLines: string[] = [];
|
||||
|
||||
for (let row = 0; row < buffer.length; row++) {
|
||||
const line = buffer.getLine(row);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = line.translateToString(true);
|
||||
const isWrapped = (line as { isWrapped?: boolean }).isWrapped === true;
|
||||
if (isWrapped && mergedLines.length > 0) {
|
||||
mergedLines[mergedLines.length - 1] += text;
|
||||
continue;
|
||||
}
|
||||
mergedLines.push(text);
|
||||
}
|
||||
|
||||
while (mergedLines.length > 0 && mergedLines[0]?.trim().length === 0) {
|
||||
mergedLines.shift();
|
||||
}
|
||||
while (mergedLines.length > 0 && mergedLines[mergedLines.length - 1]?.trim().length === 0) {
|
||||
mergedLines.pop();
|
||||
}
|
||||
|
||||
return mergedLines.slice(-limit);
|
||||
}
|
||||
|
||||
function stripAnsiSequences(input: string): string {
|
||||
return input.replace(
|
||||
/\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\].*?(?:\x07|\x1b\\))/g,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
function extractLastOutputLinesFromText(text: string, limit: number): string[] {
|
||||
const normalized = stripAnsiSequences(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
const lines = normalized.split("\n").map((line) => line.trimEnd());
|
||||
while (lines[0]?.trim().length === 0) {
|
||||
lines.shift();
|
||||
}
|
||||
while (lines[lines.length - 1]?.trim().length === 0) {
|
||||
lines.pop();
|
||||
}
|
||||
return lines.slice(-limit);
|
||||
}
|
||||
|
||||
function cellsToPlainText(cells: TerminalCell[], options: { stripAnsi: boolean }): string {
|
||||
const text = cells.map((cell) => cell.char).join("").trimEnd();
|
||||
return options.stripAnsi ? stripAnsi(text) : text;
|
||||
@@ -320,15 +516,23 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
rows = 24,
|
||||
cols = 80,
|
||||
name = "Terminal",
|
||||
command,
|
||||
args = [],
|
||||
} = options;
|
||||
const resolvedShell = shell ?? resolveDefaultTerminalShell();
|
||||
|
||||
const id = randomUUID();
|
||||
const id = options.id ?? randomUUID();
|
||||
const listeners = new Set<(msg: ServerMessage) => void>();
|
||||
const exitListeners = new Set<() => void>();
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
const titleChangeListeners = new Set<(title?: string) => void>();
|
||||
let killed = false;
|
||||
let disposed = false;
|
||||
let exitEmitted = false;
|
||||
let exitInfo: TerminalExitInfo | null = null;
|
||||
let recentOutputText = "";
|
||||
let title: string | undefined;
|
||||
let pendingTitle: string | undefined;
|
||||
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Create xterm.js headless terminal
|
||||
const terminal = new Terminal({
|
||||
@@ -341,18 +545,43 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
ensureNodePtySpawnHelperExecutableForCurrentPlatform();
|
||||
|
||||
// Create PTY
|
||||
const ptyProcess = pty.spawn(resolvedShell, [], {
|
||||
const spawnCommand = command ?? resolvedShell;
|
||||
const spawnArgs = command ? args : [];
|
||||
const ptyProcess = pty.spawn(spawnCommand, spawnArgs, {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
env: buildTerminalEnvironment({ shell: spawnCommand, env }),
|
||||
});
|
||||
|
||||
function emitTitleChange(nextTitle: string | undefined): void {
|
||||
if (title === nextTitle) {
|
||||
return;
|
||||
}
|
||||
title = nextTitle;
|
||||
for (const listener of Array.from(titleChangeListeners)) {
|
||||
try {
|
||||
listener(title);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
for (const listener of Array.from(listeners)) {
|
||||
try {
|
||||
listener({ type: "titleChange", title });
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialTitle = command
|
||||
? humanizeProcessTitle([command, ...args].join(" ")) ??
|
||||
normalizeProcessTitle([command, ...args].join(" "))
|
||||
: undefined;
|
||||
emitTitleChange(initialTitle);
|
||||
|
||||
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
|
||||
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
|
||||
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
|
||||
@@ -362,14 +591,41 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return false;
|
||||
});
|
||||
|
||||
function emitExit(): void {
|
||||
const disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
}
|
||||
pendingTitle = nextTitle.trim().length > 0 ? nextTitle : undefined;
|
||||
if (titleDebounceTimer) {
|
||||
clearTimeout(titleDebounceTimer);
|
||||
}
|
||||
titleDebounceTimer = setTimeout(() => {
|
||||
titleDebounceTimer = null;
|
||||
emitTitleChange(pendingTitle);
|
||||
}, TERMINAL_TITLE_DEBOUNCE_MS);
|
||||
});
|
||||
|
||||
function buildExitInfo(input?: { exitCode?: number | null; signal?: number | null }): TerminalExitInfo {
|
||||
const lastOutputLines = extractLastOutputLines(terminal, TERMINAL_EXIT_OUTPUT_LINE_LIMIT);
|
||||
return {
|
||||
exitCode: input?.exitCode ?? null,
|
||||
signal: input?.signal && input.signal > 0 ? input.signal : null,
|
||||
lastOutputLines:
|
||||
lastOutputLines.length > 0
|
||||
? lastOutputLines
|
||||
: extractLastOutputLinesFromText(recentOutputText, TERMINAL_EXIT_OUTPUT_LINE_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function emitExit(info: TerminalExitInfo): void {
|
||||
if (exitEmitted) {
|
||||
return;
|
||||
}
|
||||
exitEmitted = true;
|
||||
exitInfo = info;
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
try {
|
||||
listener();
|
||||
listener(info);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
@@ -382,14 +638,24 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
if (titleDebounceTimer) {
|
||||
clearTimeout(titleDebounceTimer);
|
||||
titleDebounceTimer = null;
|
||||
}
|
||||
disposeTitleChangeSubscription.dispose();
|
||||
terminal.dispose();
|
||||
listeners.clear();
|
||||
exitListeners.clear();
|
||||
titleChangeListeners.clear();
|
||||
}
|
||||
|
||||
// Pipe PTY output to terminal emulator
|
||||
ptyProcess.onData((data) => {
|
||||
if (killed) return;
|
||||
recentOutputText = `${recentOutputText}${data}`;
|
||||
if (recentOutputText.length > TERMINAL_EXIT_OUTPUT_CHAR_LIMIT) {
|
||||
recentOutputText = recentOutputText.slice(-TERMINAL_EXIT_OUTPUT_CHAR_LIMIT);
|
||||
}
|
||||
terminal.write(data, () => {
|
||||
if (disposed || killed) {
|
||||
return;
|
||||
@@ -400,9 +666,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
});
|
||||
});
|
||||
|
||||
ptyProcess.onExit(() => {
|
||||
ptyProcess.onExit((event) => {
|
||||
killed = true;
|
||||
emitExit();
|
||||
emitExit(
|
||||
buildExitInfo({
|
||||
exitCode: event.exitCode,
|
||||
signal: event.signal,
|
||||
}),
|
||||
);
|
||||
disposeResources();
|
||||
});
|
||||
|
||||
@@ -413,6 +684,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
grid: extractGrid(terminal),
|
||||
scrollback: extractScrollback(terminal),
|
||||
cursor: extractCursorState(terminal),
|
||||
...(title ? { title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,11 +727,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function onExit(listener: () => void): () => void {
|
||||
function onExit(listener: (info: TerminalExitInfo) => void): () => void {
|
||||
if (killed) {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
listener();
|
||||
listener(exitInfo ?? buildExitInfo());
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
@@ -473,11 +745,38 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
function onTitleChange(listener: (title?: string) => void): () => void {
|
||||
titleChangeListeners.add(listener);
|
||||
if (title !== undefined) {
|
||||
queueMicrotask(() => {
|
||||
if (disposed || !titleChangeListeners.has(listener)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
listener(title);
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
titleChangeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getTitle(): string | undefined {
|
||||
return title;
|
||||
}
|
||||
|
||||
function getExitInfo(): TerminalExitInfo | null {
|
||||
return exitInfo;
|
||||
}
|
||||
|
||||
function kill(): void {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
ptyProcess.kill();
|
||||
emitExit();
|
||||
emitExit(buildExitInfo());
|
||||
}
|
||||
disposeResources();
|
||||
}
|
||||
@@ -492,8 +791,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
||||
send,
|
||||
subscribe,
|
||||
onExit,
|
||||
onTitleChange,
|
||||
getSize,
|
||||
getState,
|
||||
getTitle,
|
||||
getExitInfo,
|
||||
kill,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user