Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
db3bd53
refactor(projects): define on-demand resolution contracts
cfal Sep 6, 2026
28823aa
feat(execution): admit work against available projects
cfal Sep 6, 2026
590a8fd
feat(web): resolve project context on demand
cfal Sep 6, 2026
300a3b7
test(integration): cover unavailable project recovery
cfal Sep 6, 2026
ff8e877
fix(web): stabilize project resolution lifecycles
cfal Sep 6, 2026
8e7c7cc
style(integration): align unavailable project test
cfal Sep 6, 2026
b6b18be
fix(projects): harden directory availability checks
cfal Sep 6, 2026
cff6aed
test(projects): pin adapter error contracts
cfal Sep 6, 2026
7fe40e6
fix(execution): gate private work by project availability
cfal Sep 6, 2026
9ea7eaf
fix(execution): preserve deferred control queueing
cfal Sep 6, 2026
695fc25
test(events): cover unavailable project notices
cfal Sep 6, 2026
e5ff177
fix(execution): avoid hidden private queue pauses
cfal Sep 6, 2026
18cd7f3
fix(execution): recheck queue pause eligibility
cfal Sep 6, 2026
e27b0ed
test(codex): update execution coordinator fixtures
cfal Sep 6, 2026
ae0974b
test(web): provide project resolution context
cfal Sep 6, 2026
6a9b381
fix(web): harden project resolution ownership
cfal Sep 6, 2026
21e072e
test: align project resolution gates after rebase
cfal Sep 7, 2026
3a494b2
test(e2e): isolate project surface demand
cfal Sep 7, 2026
967aa37
test(execution): align drainer fixtures after rebase
cfal Sep 7, 2026
a224fcc
fix(web): preserve project surfaces during refresh
cfal Sep 7, 2026
5985d19
fix(commands): propagate relocation session commit failures
cfal Sep 7, 2026
73c8e7b
fix(messages): classify unavailable project targets
cfal Sep 7, 2026
c74e3f9
fix(web): fence async project interactions
cfal Sep 7, 2026
c722718
fix(web): close project lifecycle races
cfal Sep 7, 2026
369d532
test(files): initialize project availability
cfal Sep 7, 2026
9565f7f
fix(web): fence project recovery refreshes
cfal Sep 7, 2026
49758f2
fix(git): retain interrupted checkout invalidation
cfal Sep 7, 2026
19d977e
fix(web): harden project resolution regressions
cfal Sep 7, 2026
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
56 changes: 56 additions & 0 deletions common/__tests__/project-resolution.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test';
import {
parseProjectResolutionResponse,
projectTargetKey,
} from '../project-resolution.ts';

const CHAT_ID = '1783725900000800';

describe('project resolution contract', () => {
it('round-trips both target and resolution variants', () => {
expect(parseProjectResolutionResponse({
target: { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project' },
resolution: { kind: 'available', effectiveProjectKey: '/real/project' },
})).toEqual({
target: { kind: 'chat', chatId: CHAT_ID, projectPath: '/workspace/project' },
resolution: { kind: 'available', effectiveProjectKey: '/real/project' },
});
expect(parseProjectResolutionResponse({
target: { kind: 'path', projectPath: '/workspace/missing' },
resolution: { kind: 'unavailable', reason: 'not-found' },
})).toEqual({
target: { kind: 'path', projectPath: '/workspace/missing' },
resolution: { kind: 'unavailable', reason: 'not-found' },
});
});

it('rejects malformed and open-ended payloads', () => {
for (const value of [
null,
{},
{
target: { kind: 'chat', chatId: 'chat', projectPath: '/workspace/project' },
resolution: { kind: 'available', effectiveProjectKey: '/real/project' },
},
{
target: { kind: 'path', projectPath: ' ' },
resolution: { kind: 'unavailable', reason: 'missing' },
},
{
target: { kind: 'path', projectPath: '/workspace/project', extra: true },
resolution: { kind: 'available', effectiveProjectKey: '/real/project' },
},
{
target: { kind: 'path', projectPath: '/workspace/project' },
resolution: { kind: 'available', effectiveProjectKey: '', reason: 'not-found' },
},
]) {
expect(parseProjectResolutionResponse(value)).toBeNull();
}
});

it('keys declared chat and raw-path targets separately', () => {
expect(projectTargetKey({ kind: 'chat', chatId: CHAT_ID, projectPath: '/project' }))
.not.toBe(projectTargetKey({ kind: 'path', projectPath: '/project' }));
});
});
2 changes: 1 addition & 1 deletion common/chat-command-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export type CommandErrorCode = Extract<
| 'PROJECT_PATH_NOT_FOUND'
| 'PROJECT_PATH_NOT_DIRECTORY'
| 'PROJECT_PATH_NATIVE_PATH_UNRESOLVED'
| 'PROJECT_UNAVAILABLE'
| 'SESSION_BUSY'
| 'REQUEST_NOT_FOUND'
| 'SERVER_SHUTTING_DOWN'
Expand Down Expand Up @@ -474,7 +475,6 @@ export interface ProjectPathPatchResponse {
projectPath: string;
effectiveProjectKey: string;
previousProjectPath: string;
previousEffectiveProjectKey: string | null;
}

export interface RunningChatsResponse {
Expand Down
1 change: 0 additions & 1 deletion common/chat-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ export interface ChatListEntry {
agentSettings: AgentSettingsEnvelope;
title: string;
projectPath: string;
effectiveProjectKey: string;
orderGroup: ChatOrderGroup;
tags: string[];
activity: {
Expand Down
2 changes: 2 additions & 0 deletions common/error-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const ERROR_CODES = [
'PROJECT_PATH_OUTSIDE_BASE',
'PROJECT_PATH_NOT_FOUND',
'PROJECT_PATH_NOT_DIRECTORY',
'PROJECT_PATH_CHANGED',
'PROJECT_UNAVAILABLE',
'PROJECT_PATH_NATIVE_PATH_UNRESOLVED',
'FOLDER_ALREADY_EXISTS',
'SAVED_SEARCH_ALREADY_EXISTS',
Expand Down
1 change: 1 addition & 0 deletions common/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"./models": "./models.ts",
"./native-session-lookup": "./native-session-lookup.ts",
"./prompt-refinement": "./prompt-refinement.ts",
"./project-resolution": "./project-resolution.ts",
"./preamble-prefix": "./preamble-prefix.ts",
"./preambles": "./preambles.ts",
"./queue-state": "./queue-state.ts",
Expand Down
83 changes: 83 additions & 0 deletions common/project-resolution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { parseChatId } from './chat-id.js';
import { isRecord } from './json.js';

export type ProjectTarget =
| { readonly kind: 'chat'; readonly chatId: string; readonly projectPath: string }
| { readonly kind: 'path'; readonly projectPath: string };

export const PROJECT_UNAVAILABLE_REASONS = [
'not-found',
'not-a-directory',
'outside-base',
'permission-denied',
] as const;

export type ProjectUnavailableReason = (typeof PROJECT_UNAVAILABLE_REASONS)[number];

export type ProjectResolution =
| { readonly kind: 'available'; readonly effectiveProjectKey: string }
| { readonly kind: 'unavailable'; readonly reason: ProjectUnavailableReason };

export interface ProjectResolutionResponse {
readonly target: ProjectTarget;
readonly resolution: ProjectResolution;
}

export function projectTargetKey(target: ProjectTarget): string {
return target.kind === 'chat'
? JSON.stringify(['chat', target.chatId, target.projectPath])
: JSON.stringify(['path', target.projectPath]);
}

export function isProjectUnavailableReason(value: unknown): value is ProjectUnavailableReason {
return typeof value === 'string'
&& PROJECT_UNAVAILABLE_REASONS.some((reason) => reason === value);
}

export function parseProjectResolutionResponse(value: unknown): ProjectResolutionResponse | null {
if (!isRecord(value) || !hasExactKeys(value, ['target', 'resolution'])) return null;
const target = parseProjectTarget(value.target);
const resolution = parseProjectResolution(value.resolution);
return target && resolution ? { target, resolution } : null;
}

function parseProjectTarget(value: unknown): ProjectTarget | null {
if (!isRecord(value) || typeof value.projectPath !== 'string' || !value.projectPath.trim()) {
return null;
}
if (value.kind === 'path' && hasExactKeys(value, ['kind', 'projectPath'])) {
return { kind: 'path', projectPath: value.projectPath };
}
if (value.kind !== 'chat' || !hasExactKeys(value, ['kind', 'chatId', 'projectPath'])) return null;
try {
return { kind: 'chat', chatId: parseChatId(value.chatId), projectPath: value.projectPath };
} catch {
return null;
}
}

function parseProjectResolution(value: unknown): ProjectResolution | null {
if (!isRecord(value)) return null;
if (
value.kind === 'available'
&& hasExactKeys(value, ['kind', 'effectiveProjectKey'])
&& typeof value.effectiveProjectKey === 'string'
&& value.effectiveProjectKey.trim()
) {
return { kind: 'available', effectiveProjectKey: value.effectiveProjectKey };
}
if (
value.kind === 'unavailable'
&& hasExactKeys(value, ['kind', 'reason'])
&& isProjectUnavailableReason(value.reason)
) {
return { kind: 'unavailable', reason: value.reason };
}
return null;
}

function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
9 changes: 0 additions & 9 deletions common/ws-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ export class ChatProjectPathUpdatedMessage {
public projectPath: string,
public effectiveProjectKey: string,
public previousProjectPath: string,
public previousEffectiveProjectKey: string | null,
) {}
}

Expand All @@ -158,7 +157,6 @@ export interface ChatProjectPathUpdatedPayload {
projectPath: string;
effectiveProjectKey: string;
previousProjectPath: string;
previousEffectiveProjectKey: string | null;
}

export class ChatSessionStoppedMessage {
Expand Down Expand Up @@ -673,19 +671,12 @@ export function parseServerWsMessage(
const projectPath = requiredStr(data.projectPath);
const effectiveProjectKey = requiredStr(data.effectiveProjectKey);
const previousProjectPath = requiredStr(data.previousProjectPath);
const previousEffectiveProjectKey = data.previousEffectiveProjectKey;
if (
previousEffectiveProjectKey !== null &&
typeof previousEffectiveProjectKey !== 'string'
)
return null;
return chatId && projectPath && effectiveProjectKey && previousProjectPath
? new ChatProjectPathUpdatedMessage(
chatId,
projectPath,
effectiveProjectKey,
previousProjectPath,
previousEffectiveProjectKey,
)
: null;
}
Expand Down
66 changes: 66 additions & 0 deletions integration-tests/tests/e2e/on-demand-project-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { describe, expect, test } from 'bun:test';
import { withE2eFixture } from '../../support/e2e-fixture.js';
import { seedLocalSettings } from '../../support/local-settings-seed.js';
import { SpaDriver } from '../../support/spa-driver.js';

describe('Lightpanda on-demand project resolution', () => {
test('keeps chat history and drafts quiet until a project surface is presented', async () => {
await withE2eFixture('on-demand-project-resolution', async (fixture) => {
await fixture.page.evaluateOnNewDocument(seedLocalSettings, { showQuickCommitTray: false });
const projectPath = join(fixture.integration.dirs.project, 'missing-project');
await mkdir(projectPath);
const chatId = fixture.integration.newChatId();
const started = await fixture.integration.client.startDirectChat({
chatId,
content: 'browser unavailable project seed',
projectPath,
agent: fixture.integration.directAgents.openAi,
});
await fixture.integration.client.waitForTurnTerminal(chatId, started.turnId);
await rm(projectPath, { recursive: true });

const resolutionRequests: string[] = [];
fixture.page.on('request', (request) => {
if (new URL(request.url()).pathname === '/api/v1/projects/resolve') {
resolutionRequests.push(request.url());
}
});
const app = new SpaDriver(fixture.page, fixture.integration);
await app.setViewport(390, 844);
await app.openChat(chatId);
await fixture.waitForSpaWebSocket();
await fixture.page.waitForFunction(
() => document.body.textContent?.includes('browser unavailable project seed') === true,
{ timeout: 20_000 },
);
await app.fill('textarea[placeholder="Reply..."]', 'draft remains editable');
await fixture.page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
expect(resolutionRequests).toEqual([]);

await app.clickButton('Files');
await fixture.page.waitForFunction(
() => document.body.textContent?.includes('Project folder unavailable') === true,
{ timeout: 20_000 },
);
expect(resolutionRequests).toHaveLength(1);
expect(await fixture.page.$eval(
'textarea[placeholder="Reply..."]',
(element) => (element as HTMLTextAreaElement).value,
)).toBe('draft remains editable');

await mkdir(projectPath);
await app.clickButton('Retry');
await fixture.page.waitForFunction(
(expectedPath) => document
.querySelector('[data-file-tree-breadcrumbs] [aria-current="location"]')
?.getAttribute('title') === expectedPath,
{ timeout: 20_000 },
projectPath,
);
expect(resolutionRequests).toHaveLength(2);
fixture.assertNoBrowserErrors();
});
}, 30_000);
});
2 changes: 1 addition & 1 deletion integration-tests/tests/server/chat-sort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ test('chat sort presets atomically reorder every persisted group and survive res
beforeStart: () => rename(secondaryProject, unavailableProject),
});
let listed = await fixture.client.listChats();
expect(listed.sessions.some((chat) => chat.id === hiddenChatId)).toBe(false);
expect(listed.sessions.some((chat) => chat.id === hiddenChatId)).toBe(true);

const createdCursor = fixture.client.markEvents();
expect(await fixture.client.sortChatOrder({ sortKey: 'created' })).toEqual({
Expand Down
15 changes: 12 additions & 3 deletions integration-tests/tests/server/garcon-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,13 +758,22 @@ describe('garcon-cli', () => {
await fs.rm(nestedProject, { recursive: true, force: true });

const status = await runCli(controlArguments(fixture, [
'status', chatId!, '--messages', '0', '--json',
'status', chatId!, '--json',
]));
expect(status.exitCode).toBe(0);
expect(JSON.parse(status.stdout)).toMatchObject({
const snapshot = JSON.parse(status.stdout);
expect(snapshot).toMatchObject({
chat: { id: chatId, projectPath: nestedProject },
transcript: { availability: 'not-requested' },
transcript: { availability: 'available' },
});
expect(userContents(snapshot.transcript.messages)).toContain('cli-removed-project');

const rejected = await runCli(controlArguments(fixture, [
'send-async', chatId!, 'cli-unavailable-follow-up',
]));
expect(rejected.exitCode).toBe(3);
expect(rejected.stdout).toBe('');
expect(rejected.stderr).toContain('submission: Project folder unavailable (not-found)');
}, { namedWorkspace: WORKSPACE });
});

Expand Down
Loading
Loading