Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 130 additions & 14 deletions ui/server/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ import os from 'node:os';

import {
getPilotDeckGateway,
isGatewayUnavailableError,
} from './pilotdeck-bridge.js';
import { mapLegacySessionPresentation } from '../../src/web/server/legacySessionPresentation.js';
import { describeWebProject, listWebProjects } from '../../src/web/server/listProjects.js';
import { listProjectSessions } from '../../src/session/index.js';
import {
resolvePilotHome,
createProjectId,
Expand Down Expand Up @@ -58,6 +61,12 @@ async function detectTaskMaster(projectPath) {
}

const directoryCache = new Map();
const parsedProjectGatewayTimeoutMs =
Number.parseInt(process.env.PILOTDECK_PROJECTS_GATEWAY_TIMEOUT_MS ?? '', 10);
const PROJECT_GATEWAY_TIMEOUT_MS = Number.isFinite(parsedProjectGatewayTimeoutMs)
? parsedProjectGatewayTimeoutMs
: 2_500;
let loggedGatewayFallback = false;

function rememberProjectDirectory(name, fullPath) {
if (!name || !fullPath) return;
Expand Down Expand Up @@ -135,9 +144,122 @@ async function readMarkedProjectPaths() {
return result;
}

function createProjectGatewayTimeoutError() {
const error = new Error(
`[projects] gateway did not become ready within ${PROJECT_GATEWAY_TIMEOUT_MS}ms`,
);
error.code = 'project_gateway_timeout';
return error;
}

function isProjectGatewayFallbackError(error) {
return error?.code === 'project_gateway_timeout' || isGatewayUnavailableError(error);
}

function logProjectGatewayFallback(error, context) {
if (loggedGatewayFallback) return;
loggedGatewayFallback = true;
console.warn(
`[projects] gateway unavailable during ${context}; using local project/session index:`,
error?.message || error,
);
}

async function getGatewayForProjectReads(context) {
const gatewayPromise = getPilotDeckGateway();
const timeoutMs = PROJECT_GATEWAY_TIMEOUT_MS;
const boundedGatewayPromise = timeoutMs > 0
? new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(createProjectGatewayTimeoutError()), timeoutMs);
gatewayPromise.then(
(gateway) => {
clearTimeout(timer);
resolve(gateway);
},
(error) => {
clearTimeout(timer);
reject(error);
},
);
})
: gatewayPromise;

try {
return await boundedGatewayPromise;
} catch (error) {
if (!isProjectGatewayFallbackError(error)) {
throw error;
}
logProjectGatewayFallback(error, context);
return null;
}
}

async function listProjectsFromGatewayOrDisk(gateway) {
if (gateway) {
try {
const { projects = [] } = await gateway.listProjects();
return { gateway, projects };
} catch (error) {
if (!isProjectGatewayFallbackError(error)) {
throw error;
}
logProjectGatewayFallback(error, 'project list');
}
}

const pilotHome = resolvePilotHome(process.env);
const { projects = [] } = await listWebProjects({ pilotHome });
return { gateway: null, projects };
}

async function listSessionsFromGatewayOrDisk(gateway, projectKey, { limit = 5, cursor } = {}) {
if (gateway) {
try {
return await gateway.listSessions({ projectKey, limit, cursor });
} catch (error) {
if (!isProjectGatewayFallbackError(error)) {
throw error;
}
logProjectGatewayFallback(error, 'session list');
}
}

const offset = cursor ? Number.parseInt(cursor, 10) : 0;
const safeOffset = Number.isFinite(offset) ? offset : 0;
const sessions = await listProjectSessions({
projectRoot: projectKey,
pilotHome: resolvePilotHome(process.env),
limit,
offset: safeOffset,
});
const nextOffset = safeOffset + sessions.length;
return {
sessions,
nextCursor: limit && sessions.length === limit ? String(nextOffset) : undefined,
};
}

async function describeProjectFromGatewayOrDisk(gateway, projectKey) {
if (gateway) {
try {
return await gateway.describeProject({ projectKey });
} catch (error) {
if (!isProjectGatewayFallbackError(error)) {
throw error;
}
logProjectGatewayFallback(error, 'project summary');
}
}

return describeWebProject(projectKey, { pilotHome: resolvePilotHome(process.env) });
}

async function getProjects(progressCallback = null) {
const gateway = await getPilotDeckGateway();
const { projects: webProjects } = await gateway.listProjects();
let gateway = await getGatewayForProjectReads('project list');
const listedProjects = await listProjectsFromGatewayOrDisk(gateway);
gateway = listedProjects.gateway;
const webProjects = listedProjects.projects;
const markedProjects = await readMarkedProjectPaths();
const markedProjectIdsByPath = new Map(
[...markedProjects.entries()].map(([id, cwd]) => [path.resolve(cwd), id]),
Expand Down Expand Up @@ -199,8 +321,7 @@ async function getProjects(progressCallback = null) {
});
}

const sessionsResult = await gateway
.listSessions({ projectKey: fullPath, limit: 5 })
const sessionsResult = await listSessionsFromGatewayOrDisk(gateway, fullPath, { limit: 5 })
.catch(() => ({ sessions: [] }));
const sessions = (sessionsResult.sessions || []).map((session) =>
toLegacySession(session, name),
Expand Down Expand Up @@ -242,18 +363,15 @@ async function getProjects(progressCallback = null) {
let generalTotal = 0;
let generalLastActivity;
try {
const generalGateway = await getPilotDeckGateway();
// Pair the first page query with describeProject so the General
// workspace gets the real session count instead of the page size.
// Without this, sessionMeta.hasMore was hardcoded `false` and the
// sidebar would silently truncate to the first 5 sessions even
// when dozens existed under ~/.pilotdeck/projects/<encoded>/chats/.
const [generalSessionsResult, generalSummary] = await Promise.all([
generalGateway
.listSessions({ projectKey: generalHome, limit: 5 })
listSessionsFromGatewayOrDisk(gateway, generalHome, { limit: 5 })
.catch(() => ({ sessions: [] })),
generalGateway
.describeProject({ projectKey: generalHome })
describeProjectFromGatewayOrDisk(gateway, generalHome)
.catch(() => null),
]);
generalSessions = (generalSessionsResult.sessions || []).map((session) =>
Expand Down Expand Up @@ -288,7 +406,7 @@ async function getProjects(progressCallback = null) {
}

async function getSessions(projectName, limit = 5, offset = 0) {
const gateway = await getPilotDeckGateway();
const gateway = await getGatewayForProjectReads('session list');
const projectPath = await extractProjectDirectory(projectName);
const cursor = offset > 0 ? String(offset) : undefined;
// Fan-out the page query and the project summary (for the authoritative
Expand All @@ -300,11 +418,9 @@ async function getSessions(projectName, limit = 5, offset = 0) {
// to the user as a button that "doesn't react" once they've already
// pulled in everything that exists.
const [listResult, summary] = await Promise.all([
gateway
.listSessions({ projectKey: projectPath, limit, cursor })
listSessionsFromGatewayOrDisk(gateway, projectPath, { limit, cursor })
.catch(() => ({ sessions: [] })),
gateway
.describeProject({ projectKey: projectPath })
describeProjectFromGatewayOrDisk(gateway, projectPath)
.catch(() => null),
]);
const sessions = (listResult.sessions || []).map((session) =>
Expand Down
116 changes: 116 additions & 0 deletions ui/server/projects.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { createProjectId, getPilotProjectChatDir } from '../../src/pilot/index.js';

const tempDirs = [];
const originalPilotHome = process.env.PILOT_HOME;
const originalGatewayTimeout = process.env.PILOTDECK_PROJECTS_GATEWAY_TIMEOUT_MS;

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
restoreEnv('PILOT_HOME', originalPilotHome);
restoreEnv('PILOTDECK_PROJECTS_GATEWAY_TIMEOUT_MS', originalGatewayTimeout);
});

describe('projects gateway fallback', () => {
it('loads Board project data from disk when the gateway is unavailable', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const pilotHome = makeTempDir('pilotdeck-projects-home-');
const projectRoot = makeTempDir('pilotdeck-project-root-');
const projectName = createProjectId(projectRoot);
writeProjectMarker(pilotHome, projectRoot);
writeSession(pilotHome, projectRoot, 'web:s_local', 'hello from local history');

const { getProjects, getSessions } = await importProjectsWithUnavailableGateway(pilotHome);

const projects = await getProjects();
const project = projects.find((item) => item.fullPath === projectRoot);

expect(projects[0]).toMatchObject({ name: 'general', fullPath: pilotHome });
expect(project).toMatchObject({
name: projectName,
displayName: projectRoot.split('/').pop(),
sessionMeta: { total: 1, hasMore: false },
});
expect(project.sessions).toHaveLength(1);
expect(project.sessions[0]).toMatchObject({
id: 'web:s_local',
firstPrompt: 'hello from local history',
__projectName: projectName,
});

const sessionsPage = await getSessions(projectName, 5, 0);

expect(sessionsPage).toMatchObject({
total: 1,
hasMore: false,
offset: 0,
limit: 5,
});
expect(sessionsPage.sessions).toHaveLength(1);
expect(sessionsPage.sessions[0]).toMatchObject({
id: 'web:s_local',
firstPrompt: 'hello from local history',
});
});
});

async function importProjectsWithUnavailableGateway(pilotHome) {
process.env.PILOT_HOME = pilotHome;
process.env.PILOTDECK_PROJECTS_GATEWAY_TIMEOUT_MS = '1';
vi.doMock('./pilotdeck-bridge.js', () => ({
getPilotDeckGateway: vi.fn(async () => {
throw new Error('[pilotdeck-bridge] gateway connect failed after 1ms: Failed to connect to gateway WebSocket.');
}),
isGatewayUnavailableError: vi.fn((error) =>
/gateway connect failed|failed to connect to gateway websocket/i.test(error?.message || String(error))),
}));
vi.doMock('./database/db.js', () => ({
applyCustomSessionNames: vi.fn(),
}));

return import('./projects.js');
}

function makeTempDir(prefix) {
const dir = mkdtempSync(join(tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}

function writeProjectMarker(pilotHome, projectRoot) {
const projectDir = join(pilotHome, 'projects', createProjectId(projectRoot));
mkdirSync(projectDir, { recursive: true });
writeFileSync(join(projectDir, '.cwd'), `${projectRoot}\n`, 'utf-8');
}

function writeSession(pilotHome, projectRoot, sessionId, text) {
const chatDir = getPilotProjectChatDir(projectRoot, pilotHome);
mkdirSync(chatDir, { recursive: true });
const entry = {
type: 'accepted_input',
createdAt: '2026-07-30T00:00:00.000Z',
messages: [
{
role: 'user',
content: [{ type: 'text', text }],
},
],
};
writeFileSync(join(chatDir, `${sessionId}.jsonl`), `${JSON.stringify(entry)}\n`, 'utf-8');
}

function restoreEnv(name, value) {
if (value === undefined) {
delete process.env[name];
} else {
process.env[name] = value;
}
}