Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"dependencies": {
"jszip": "^3.10.1",
"ms": "2.1.3",
"puppeteer-core": "^24.42.0"
},
"devDependencies": {
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/main/ask-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ describe('ask-ipc', () => {
await expect(second).resolves.toEqual({ status: 'cancelled', answers: [] });
});

it('lists pending ask requests so the renderer can recover a missed event', async () => {
handlers.clear();
registerAskIpc();
const send = vi.fn();
const fakeWindow = {
isDestroyed: () => false,
webContents: { send },
} as unknown as Electron.BrowserWindow;
const inFlight = requestAsk('session-recover', sampleInput, () => fakeWindow);
const listPending = handlers.get('ask:list-pending');
if (!listPending) throw new Error('ask:list-pending handler not registered');

const result = listPending(null, undefined);

expect(result).toEqual([
expect.objectContaining({ sessionId: 'session-recover', input: sampleInput }),
]);
cancelPendingAskRequests('session-recover');
await expect(inFlight).resolves.toEqual({ status: 'cancelled', answers: [] });
});

it('rejects malformed answers for a known request instead of leaving it pending', async () => {
handlers.clear();
registerAskIpc();
Expand Down
13 changes: 12 additions & 1 deletion apps/desktop/src/main/ask-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface PendingAsk {
resolve: (result: AskResult) => void;
reject: (reason?: unknown) => void;
sessionId: string;
input: AskInput;
}

const pending = new Map<string, PendingAsk>();
Expand All @@ -31,6 +32,8 @@ export interface AskRequestPayload {
}

export function registerAskIpc(): void {
ipcMain.handle('ask:list-pending', () => listPendingAskRequests());

ipcMain.handle('ask:resolve', (_event, raw: unknown) => {
const requestId = readRequestId(raw, 'ask:resolve');
const entry = pending.get(requestId);
Expand Down Expand Up @@ -70,7 +73,7 @@ export function requestAsk(
): Promise<AskResult> {
const requestId = `ask-${randomUUID()}`;
return new Promise<AskResult>((resolve, reject) => {
pending.set(requestId, { resolve, reject, sessionId });
pending.set(requestId, { resolve, reject, sessionId, input });
const win = getMainWindow();
if (!win || win.isDestroyed()) {
pending.delete(requestId);
Expand All @@ -88,6 +91,14 @@ export function requestAsk(
});
}

export function listPendingAskRequests(): AskRequestPayload[] {
return Array.from(pending, ([requestId, entry]) => ({
requestId,
sessionId: entry.sessionId,
input: entry.input,
}));
}

export function cancelPendingAskRequests(sessionId: string): void {
for (const [id, entry] of pending) {
if (entry.sessionId !== sessionId) continue;
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/design-workspace.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs';
import { copyFile, mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import type { Design } from '@open-codesign/shared';
import type { Design, WorkspaceMode } from '@open-codesign/shared';
import { type BrowserWindow, dialog, shell } from 'electron';
import { getLogger } from './logger';
import {
Expand Down Expand Up @@ -145,6 +145,7 @@ export async function bindWorkspace(
designId: string,
workspacePath: string | null,
migrateFiles: boolean,
workspaceMode?: WorkspaceMode,
): Promise<Design> {
const current = requireDesign(db, designId);

Expand Down Expand Up @@ -183,7 +184,7 @@ export async function bindWorkspace(
await migrateWorkspaceFiles(db, designId, normalizedPath);
}

const updated = updateDesignWorkspace(db, designId, normalizedPath);
const updated = updateDesignWorkspace(db, designId, normalizedPath, workspaceMode);
if (updated === null) {
throw new Error(`Design not found: ${designId}`);
}
Expand Down
26 changes: 12 additions & 14 deletions apps/desktop/src/main/done-verify.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
formatRuntimeLoadError,
Expand Down Expand Up @@ -34,23 +37,18 @@ describe('done runtime verifier error formatting', () => {
});

it('allows only the verifier file for file:// requests', () => {
const verifyFilePath = join(tmpdir(), 'codesign-done', 'verify.html');
expect(isDoneVerifierRequestAllowed(pathToFileURL(verifyFilePath).href, verifyFilePath)).toBe(
true,
);
expect(
isDoneVerifierRequestAllowed(
'file:///tmp/codesign-done/verify.html',
'/tmp/codesign-done/verify.html',
),
).toBe(true);
expect(
isDoneVerifierRequestAllowed(
'file:///Users/me/private.txt',
'/tmp/codesign-done/verify.html',
pathToFileURL(join(tmpdir(), 'private.txt')).href,
verifyFilePath,
),
).toBe(false);
expect(
isDoneVerifierRequestAllowed(
'https://fonts.googleapis.com/css2',
'/tmp/codesign-done/verify.html',
),
).toBe(true);
expect(isDoneVerifierRequestAllowed('https://fonts.googleapis.com/css2', verifyFilePath)).toBe(
true,
);
});
});
14 changes: 7 additions & 7 deletions apps/desktop/src/main/exporter-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import { CodesignError } from '@open-codesign/shared';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
Expand Down Expand Up @@ -64,7 +64,7 @@ describe('parseRequest', () => {
expect(result.sourcePath).toBe('screens/home/index.html');
expect(exportAssetOptions(result)).toMatchObject({
assetRootPath: '/workspace',
assetBasePath: '/workspace/screens/home',
assetBasePath: resolve('/workspace', 'screens/home'),
sourcePath: 'screens/home/index.html',
});
});
Expand Down Expand Up @@ -103,7 +103,7 @@ describe('parseRequest', () => {

expect(result.sourcePath).toBe('screens/home/App.jsx');
expect(exportAssetOptions(result)).toMatchObject({
assetBasePath: '/workspace/screens/home',
assetBasePath: resolve('/workspace', 'screens/home'),
sourcePath: 'screens/home/App.jsx',
});
});
Expand Down Expand Up @@ -131,7 +131,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/Launch-Deck-Q2-Home-2026-05-05-102030.pptx');
expect(out).toBe(join('/Users/roy/Downloads', 'Launch-Deck-Q2-Home-2026-05-05-102030.pptx'));
});

it('falls back to an open-codesign name inside Downloads when no design name is available', () => {
Expand All @@ -143,7 +143,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/open-codesign-App-2026-05-05-102030.md');
expect(out).toBe(join('/Users/roy/Downloads', 'open-codesign-App-2026-05-05-102030.md'));
});

it('treats legacy defaultFilename as a Downloads filename, not a cwd-relative path', () => {
Expand All @@ -154,7 +154,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/codesign-2026-05-05T06-04-43.html');
expect(out).toBe(join('/Users/roy/Downloads', 'codesign-2026-05-05T06-04-43.html'));
});

it('keeps legacy defaultFilename on the requested format extension', () => {
Expand All @@ -165,7 +165,7 @@ describe('export path helpers', () => {
now: new Date('2026-05-05T10:20:30.000Z'),
});

expect(out).toBe('/Users/roy/Downloads/preview.html.pdf');
expect(out).toBe(join('/Users/roy/Downloads', 'preview.html.pdf'));
});

it('keeps export files on the selected format extension', () => {
Expand Down
41 changes: 40 additions & 1 deletion apps/desktop/src/main/generation-ipc.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CancelGenerationPayloadV1, CodesignError } from '@open-codesign/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
acquireInFlightWorkspaceGeneration,
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
Expand Down Expand Up @@ -197,12 +198,50 @@ describe('withInFlightGenerationForDesign', () => {
const controller = makeController();
const inFlight = new Map([['gen-1', controller]]);
const inFlightByDesign = new Map([['design-1', { generationId: 'gen-1', startedAt: 1234 }]]);
const inFlightByWorkspace = new Map([
['/workspace', { generationId: 'gen-1', startedAt: 1234 }],
]);
const logIpc = { info: vi.fn() };

cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign);
cancelGenerationRequest('gen-1', inFlight, logIpc, inFlightByDesign, inFlightByWorkspace);

expect(inFlight.has('gen-1')).toBe(false);
expect(inFlightByDesign.has('design-1')).toBe(false);
expect(inFlightByWorkspace.has('/workspace')).toBe(false);
});
});

describe('acquireInFlightWorkspaceGeneration', () => {
it('rejects a second generation for the same workspace while the first is running', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const release = acquireInFlightWorkspaceGeneration('gen-1', '/workspace', inFlightByWorkspace);

expect(() =>
acquireInFlightWorkspaceGeneration('gen-2', '/workspace', inFlightByWorkspace),
).toThrow(CodesignError);

release();
expect(inFlightByWorkspace.has('/workspace')).toBe(false);
});

it('allows different workspaces to run concurrently', () => {
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();
const releaseOne = acquireInFlightWorkspaceGeneration(
'gen-1',
'/workspace-a',
inFlightByWorkspace,
);
const releaseTwo = acquireInFlightWorkspaceGeneration(
'gen-2',
'/workspace-b',
inFlightByWorkspace,
);

expect([...inFlightByWorkspace.keys()].sort()).toEqual(['/workspace-a', '/workspace-b']);

releaseOne();
releaseTwo();
expect(inFlightByWorkspace.size).toBe(0);
});
});

Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/main/generation-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function cancelGenerationRequest(
inFlight: Map<string, AbortController>,
logIpc: CancellationLogger,
inFlightByDesign?: Map<string, InFlightGeneration>,
inFlightByWorkspace?: Map<string, InFlightGeneration>,
): void {
if (typeof raw !== 'string') {
throw new CodesignError(
Expand All @@ -32,6 +33,11 @@ export function cancelGenerationRequest(
if (generation.generationId === raw) inFlightByDesign.delete(designId);
}
}
if (inFlightByWorkspace !== undefined) {
for (const [workspaceKey, generation] of inFlightByWorkspace) {
if (generation.generationId === raw) inFlightByWorkspace.delete(workspaceKey);
}
}
logIpc.info('generate.cancelled', { id: raw });
}

Expand Down Expand Up @@ -77,6 +83,27 @@ export async function withInFlightGenerationForDesign<T>(
}
}

export function acquireInFlightWorkspaceGeneration(
id: string,
workspaceKey: string,
inFlightByWorkspace: Map<string, InFlightGeneration>,
): () => void {
const existing = inFlightByWorkspace.get(workspaceKey);
if (existing !== undefined && existing.generationId !== id) {
throw new CodesignError(
'A generation is already running for this workspace. Wait for it to finish or stop it before continuing.',
'GENERATION_ALREADY_RUNNING',
);
}
const startedAt = existing?.startedAt ?? Date.now();
inFlightByWorkspace.set(workspaceKey, { generationId: id, startedAt });
return () => {
if (inFlightByWorkspace.get(workspaceKey)?.generationId === id) {
inFlightByWorkspace.delete(workspaceKey);
}
};
}

export function listInFlightGenerations(
inFlightByDesign: ReadonlyMap<string, InFlightGeneration>,
): Array<{ designId: string; generationId: string; startedAt: number }> {
Expand Down
19 changes: 18 additions & 1 deletion apps/desktop/src/main/ipc/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { CHATGPT_CODEX_PROVIDER_ID, getCodexTokenStore } from '../codex-oauth-ip
import { makeRuntimeVerifier } from '../done-verify';
import { app, ipcMain } from '../electron-runtime';
import {
acquireInFlightWorkspaceGeneration,
armGenerationTimeout,
cancelGenerationRequest,
extractGenerationTimeoutError,
Expand Down Expand Up @@ -673,6 +674,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
/** In-flight requests: generationId → AbortController */
const inFlight = new Map<string, AbortController>();
const inFlightByDesign = new Map<string, { generationId: string; startedAt: number }>();
const inFlightByWorkspace = new Map<string, { generationId: string; startedAt: number }>();

const armTimeout = (id: string, controller: AbortController) =>
armGenerationTimeout(
Expand Down Expand Up @@ -820,7 +822,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

const t0 = Date.now();
let clearTimeoutGuard: () => void = () => {};
let releaseWorkspaceGeneration: () => void = () => {};
try {
releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration(
id,
workspaceRoot,
inFlightByWorkspace,
);
clearTimeoutGuard = await armTimeout(id, controller);
const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID;
let capturedMessages: DesignBriefConversationMessages | null = null;
Expand Down Expand Up @@ -1251,6 +1259,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
throw rethrow;
} finally {
clearTimeoutGuard();
releaseWorkspaceGeneration();
}
},
);
Expand All @@ -1259,7 +1268,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

ipcMain.handle('codesign:v1:cancel-generation', (_e, raw: unknown) => {
const { generationId } = CancelGenerationPayloadV1.parse(raw);
cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign);
cancelGenerationRequest(generationId, inFlight, logIpc, inFlightByDesign, inFlightByWorkspace);
});

ipcMain.handle('codesign:v1:generation-status', () => ({
Expand Down Expand Up @@ -1334,7 +1343,13 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe

const t0 = Date.now();
let clearTimeoutGuard: () => void = () => {};
let releaseWorkspaceGeneration: () => void = () => {};
try {
releaseWorkspaceGeneration = acquireInFlightWorkspaceGeneration(
id,
workspaceRoot,
inFlightByWorkspace,
);
clearTimeoutGuard = await armTimeout(id, controller);
const isCodex = active.model.provider === CHATGPT_CODEX_PROVIDER_ID;
const result = await runGenerate(
Expand Down Expand Up @@ -1402,6 +1417,7 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
throw rethrow;
} finally {
clearTimeoutGuard();
releaseWorkspaceGeneration();
}
},
);
Expand Down Expand Up @@ -1468,5 +1484,6 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
}
inFlight.clear();
inFlightByDesign.clear();
inFlightByWorkspace.clear();
};
}
Loading
Loading