diff --git a/apps/desktop/forge-node-pty.ts b/apps/desktop/forge-node-pty.ts new file mode 100644 index 0000000000..e8f10c4dc3 --- /dev/null +++ b/apps/desktop/forge-node-pty.ts @@ -0,0 +1,55 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** Prepare node-pty in the disposable package copy, never in the source checkout. */ +export function preparePackagedNodePty(buildPath: string, platform: string, arch: string) { + const packageDir = path.join(buildPath, 'node_modules', 'node-pty'); + // The pinned npm package ships macOS/Windows prebuilds; Linux still builds from source. + if (platform !== 'win32' && platform !== 'darwin') { + return { + rebuild: true, + nativePath: path.join(packageDir, 'build', 'Release', 'pty.node'), + }; + } + + const prebuildDir = path.join(packageDir, 'prebuilds', `${platform}-${arch}`); + const required = + platform === 'win32' + ? [ + 'pty.node', + 'conpty.node', + 'conpty_console_list.node', + 'winpty-agent.exe', + 'winpty.dll', + path.join('conpty', 'conpty.dll'), + path.join('conpty', 'OpenConsole.exe'), + ] + : ['pty.node', 'spawn-helper']; + const missing = required.filter((file) => { + try { + const info = fs.statSync(path.join(prebuildDir, file)); + return !info.isFile() || info.size === 0; + } catch { + return true; + } + }); + if (missing.length) { + throw new Error( + `[forge:afterCopy] node-pty prebuild for ${platform}-${arch} is incomplete: ${missing.join(', ')}. ` + + 'Reinstall workspace dependencies with pnpm install --force --frozen-lockfile, then package again.', + ); + } + + // node-pty loads build/Release and build/Debug before prebuilds. Remove copied + // local bindings so a stale Node ABI or another architecture cannot shadow the target. + for (const variant of ['Release', 'Debug']) { + for (const file of ['pty.node', 'conpty.node', 'conpty_console_list.node']) { + fs.rmSync(path.join(packageDir, 'build', variant, file), { force: true }); + } + } + if (platform === 'darwin') { + // pnpm can lose the executable bit while importing spawn-helper from its store. + fs.chmodSync(path.join(prebuildDir, 'spawn-helper'), 0o755); + } + return { rebuild: false, nativePath: path.join(prebuildDir, 'pty.node') }; +} diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 71bebde78b..3c3be41d2b 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -21,6 +21,7 @@ import { resolveCindyRegion, } from '@cindy/maker-shared/brand-identity'; import { stageMacIOSSimulatorHelper } from './forge-ios-simulator-helper'; +import { preparePackagedNodePty } from './forge-node-pty'; import { stagePackagedThirdPartyNotices } from './forge-third-party-notices'; import { swiftTargetTriple, @@ -190,8 +191,8 @@ const NATIVE_RUNTIME_DEPS = [ // 无需带运行时闭包。注意: 该 runtime 用 CDP 接管用户已装 Chrome, 不需要 // playwright 自带的浏览器二进制, 故只带 JS 模块即可。 'playwright-core', - // node-pty (RSB 终端 tab 的 PTY 后端): .node 原生模块, 跟 better-sqlite3 同款 —— - // 必须 electron-rebuild (Node ABI ≠ Electron ABI), 必须随 packaged app 带, + // node-pty (RSB 终端 tab 的 PTY 后端): macOS/Windows 使用包内 N-API 预编译件, + // Linux 通过 electron-rebuild 编译;各平台都必须随 packaged app 带, // AutoUnpackNativesPlugin 会把 .node 提取到 app.asar.unpacked/。main 进程通过 // createRequire 在运行时 require, 不让 vite bundle (见 vite.main.config.ts external)。 'node-pty', @@ -405,24 +406,26 @@ function bundleNativeDeps(buildPath: string, targetPlatform: string, targetArch: copyRuntimeDependencyTrees(READ_SHEET_RUNTIME_PACKAGES, destModules); } -// 针对 packaged buildPath 的 node_modules 强制重建 better-sqlite3 —— force:true 确保 -// 即使根 node_modules 里的 .node 是 Node ABI(pnpm install 默认),也会被 Electron ABI -// 覆盖重编。编完的 .node 落在 build/Release/better_sqlite3.node,下游 -// AutoUnpackNativesPlugin 会在 asar 打包时把它提取到 app.asar.unpacked/。 +// better-sqlite3 仍按 Electron ABI 重编。node-pty 在 macOS/Windows 使用 npm 包内 +// 的 N-API 预编译件;缺件意味着依赖不完整,不能悄悄转入源码编译。Linux 继续重编。 async function rebuildNativeDepsInPackage( buildPath: string, electronVersion: string, + platform: string, arch: string, ): Promise { + const nodePty = preparePackagedNodePty(buildPath, platform, arch); + const modules = ['better-sqlite3']; + if (nodePty.rebuild) modules.push('node-pty'); console.log( - `[forge:afterCopy] rebuilding native modules (better-sqlite3, node-pty) for Electron ${electronVersion} (${arch})...`, + `[forge:afterCopy] rebuilding native modules (${modules.join(', ')}) for Electron ${electronVersion} (${arch})...`, ); await electronRebuild({ buildPath, electronVersion, arch, force: true, - onlyModules: ['better-sqlite3', 'node-pty'], + onlyModules: modules, }); const sqliteNative = path.join( buildPath, @@ -435,18 +438,9 @@ async function rebuildNativeDepsInPackage( if (!fs.existsSync(sqliteNative)) { throw new Error(`[forge:afterCopy] rebuild reported success but ${sqliteNative} is missing`); } - // node-pty 的 .node 在 build/Release/pty.node;Windows 上同名,Linux/macOS 同名。 - // 跟 better-sqlite3 一样,缺了直接抛出,避免发出无法启动 PTY 的包。 - const ptyNative = path.join( - buildPath, - 'node_modules', - 'node-pty', - 'build', - 'Release', - 'pty.node', - ); + const ptyNative = nodePty.nativePath; if (!fs.existsSync(ptyNative)) { - throw new Error(`[forge:afterCopy] rebuild reported success but ${ptyNative} is missing`); + throw new Error(`[forge:afterCopy] node-pty native module missing: ${ptyNative}`); } // node-pty 被整目录纳入 asar.unpack(为放出 spawn-helper / winpty 等运行时二进制), @@ -1979,7 +1973,7 @@ const config: ForgeConfig = { (async () => { try { bundleNativeDeps(buildPath, platform, arch); - await rebuildNativeDepsInPackage(buildPath, electronVersion, arch); + await rebuildNativeDepsInPackage(buildPath, electronVersion, platform, arch); copySqliteVecBinary(buildPath, platform, arch); callback(); } catch (err) { diff --git a/apps/desktop/scripts/native-deps-packaging.test.mjs b/apps/desktop/scripts/native-deps-packaging.test.mjs new file mode 100644 index 0000000000..c24fb680b5 --- /dev/null +++ b/apps/desktop/scripts/native-deps-packaging.test.mjs @@ -0,0 +1,113 @@ +import * as fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { preparePackagedNodePty } from '../forge-node-pty'; + +const windowsFiles = [ + 'pty.node', + 'conpty.node', + 'conpty_console_list.node', + 'winpty-agent.exe', + 'winpty.dll', + path.join('conpty', 'conpty.dll'), + path.join('conpty', 'OpenConsole.exe'), +]; +let root; +let fixtureId = 0; +beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cindy-pty-package-')); +}); +afterAll(() => { + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture(platform = 'win32', arch = 'x64') { + const buildPath = path.join(root, String(++fixtureId)); + const packageDir = path.join(buildPath, 'node_modules', 'node-pty'); + const prebuildDir = path.join(packageDir, 'prebuilds', `${platform}-${arch}`); + const files = platform === 'win32' ? windowsFiles : ['pty.node', 'spawn-helper']; + for (const file of files) { + const target = path.join(prebuildDir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'fixture binary'); + } + return { buildPath, packageDir, prebuildDir, files }; +} + +describe('native dependency packaging', () => { + it.each([ + ['win32', 'x64'], + ['win32', 'arm64'], + ['darwin', 'x64'], + ['darwin', 'arm64'], + ])('uses the complete %s/%s prebuild without requiring compilation', (platform, arch) => { + const f = fixture(platform, arch); + expect(preparePackagedNodePty(f.buildPath, platform, arch)).toEqual({ + rebuild: false, + nativePath: path.join(f.prebuildDir, 'pty.node'), + }); + for (const file of f.files) { + expect(fs.readFileSync(path.join(f.prebuildDir, file), 'utf8')).toBe('fixture binary'); + } + }); + + it.each(windowsFiles)('rejects a missing Windows runtime file: %s', (file) => { + const f = fixture(); + fs.unlinkSync(path.join(f.prebuildDir, file)); + expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'x64')).toThrow( + /prebuild.*incomplete.*Reinstall workspace dependencies/, + ); + }); + + it.each(['empty', 'directory'])('rejects an %s binding instead of compiling it', (kind) => { + const f = fixture(); + const file = path.join(f.prebuildDir, 'conpty.node'); + fs.unlinkSync(file); + if (kind === 'empty') fs.writeFileSync(file, ''); + else fs.mkdirSync(file); + expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'x64')).toThrow('conpty.node'); + }); + + it('does not accept prebuilds for another architecture or a stale local build', () => { + const f = fixture('win32', 'x64'); + const stale = path.join(f.packageDir, 'build', 'Release', 'pty.node'); + fs.mkdirSync(path.dirname(stale), { recursive: true }); + fs.writeFileSync(stale, 'local binding'); + expect(() => preparePackagedNodePty(f.buildPath, 'win32', 'arm64')).toThrow('win32-arm64'); + expect(fs.readFileSync(stale, 'utf8')).toBe('local binding'); + }); + + it('prevents copied Release and Debug bindings from shadowing valid target prebuilds', () => { + const f = fixture(); + const stale = ['Release', 'Debug'].flatMap((variant) => + ['pty.node', 'conpty.node', 'conpty_console_list.node'].map((file) => + path.join(f.packageDir, 'build', variant, file), + ), + ); + for (const file of stale) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, 'local binding'); + } + const neighbor = path.join(f.packageDir, 'build', 'Release', 'keep.txt'); + fs.writeFileSync(neighbor, 'keep'); + preparePackagedNodePty(f.buildPath, 'win32', 'x64'); + expect(stale.every((file) => !fs.existsSync(file))).toBe(true); + expect(fs.readFileSync(neighbor, 'utf8')).toBe('keep'); + expect(fs.readFileSync(path.join(f.prebuildDir, 'conpty.node'), 'utf8')).toBe('fixture binary'); + }); + + it('rejects a missing macOS spawn-helper before changing the package', () => { + const f = fixture('darwin', 'arm64'); + fs.unlinkSync(path.join(f.prebuildDir, 'spawn-helper')); + expect(() => preparePackagedNodePty(f.buildPath, 'darwin', 'arm64')).toThrow('spawn-helper'); + }); + + it.each(['x64', 'arm64'])('keeps the existing Linux/%s source build', (arch) => { + const f = fixture('linux', arch); + expect(preparePackagedNodePty(f.buildPath, 'linux', arch)).toEqual({ + rebuild: true, + nativePath: path.join(f.packageDir, 'build', 'Release', 'pty.node'), + }); + }); +}); diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index 0c5d2a4ecc..3d3ca553c3 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -383,8 +383,8 @@ import { } from './cindy-make/versionService.js'; import { deliverCindyVersionOpenEvents, + finishCindyVersionStartup, isCindyVersionLaunchPending, - recordCindyVersionActive, watchCindyVersionStartupResult, } from './cindy-make/versionStartup.js'; import { @@ -953,8 +953,10 @@ import { findOpenFolderInArgv, findOpenShareFileInArgv, setDeepLinkMainWindow, + focusMainWindow as activateMainWindow, takePendingDeepLink, } from './deepLink.js'; +import { createMakeTestWindowBehavior } from './cindy-make/testWindowBehavior.js'; import { registerFolderContextMenu } from './folderContextMenu.js'; import { healWindowsShortcuts } from './windowsShortcutSelfHeal.js'; import { CURRENT_APP_ID, CURRENT_CINDY_REGION } from '../shared/brandRegion.js'; @@ -3790,7 +3792,7 @@ if ( ); app.quit(); } else { - recordCindyVersionActive(); + finishCindyVersionStartup(); app.on('second-instance', (_event, argv) => { // Windows: 用户点 cindy://(或历史 xdt-maker://)链接 / 右键 "通过 Cindy 打开" 时, // OS 会再起一个本 app 实例; 单例锁把它 redirect 成 second-instance 事件, @@ -4017,8 +4019,15 @@ const createWindow = () => { }); // Main-window close policy is explicit because hidden utility windows (for // example the prewarmed global voice overlay) can keep the process alive. + const makeTestWindow = createMakeTestWindowBehavior({ + isPackaged: app.isPackaged, + environment: process.env, + focus: () => activateMainWindow(), + quit: () => app.quit(), + }); mainWindow.on('close', (event) => { if (isQuitting) return; + if (makeTestWindow.close(event)) return; // macOS: keep the window + renderer alive and hide only, so Dock activation // can restore it without remounting the renderer. if (process.platform === 'darwin') { @@ -4117,6 +4126,7 @@ const createWindow = () => { showMainWindowAndRestoreFullscreen(mainWindow, { restoreFullscreen: shouldRestoreMacFullscreen, }); + makeTestWindow.ready(); refreshWindowsAppBadge(); if (!app.isPackaged || isCindyVersionLaunchPending()) markDesktopDevWindowReady(mainWindow.webContents.getOSProcessId()); @@ -7614,7 +7624,7 @@ const registerIpcHandlers = () => { const message = (error as { message?: unknown }).message; const reason = typeof message === 'string' - ? /^\[PRECONDITION_FAILED\]\s*(busy|dirty|conflict|cleanupFailed|directoryBusy|unavailable)$/.exec( + ? /^\[PRECONDITION_FAILED\]\s*(busy|dirty|conflict|cleanupFailed|directoryBusy|unavailable|stopFailed)$/.exec( message, )?.[1] : undefined; @@ -7662,6 +7672,7 @@ const registerIpcHandlers = () => { assertTrustedAppRendererEvent(event); return actCindyVersion(action, id); }); + onQuit('cindy-make-tests', () => cindyMakeTestController.stopAllAndWait(), 'async'); app.once('will-quit', () => cindyMakeTestController.stopAll()); ipcMain.handle( 'app:cindy-make-test', diff --git a/apps/desktop/src/main/cindy-make/__tests__/buildRollback.test.ts b/apps/desktop/src/main/cindy-make/__tests__/buildRollback.test.ts new file mode 100644 index 0000000000..0f7eb8d7e3 --- /dev/null +++ b/apps/desktop/src/main/cindy-make/__tests__/buildRollback.test.ts @@ -0,0 +1,161 @@ +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { afterEach, expect, it, vi } from 'vitest'; +import { historyBuildRollback } from '../buildRollback'; +import { CindyMakeHistoryStore } from '../historyStore'; +import type { MakeFeatureReceipt } from '../../../shared/cindyMakeHistory'; +import type { ContentGit } from '../sourceContent'; + +vi.mock('../sourceContent', () => ({ + snapshotContent: (git: ContentGit, source: string) => git(['snapshot'], source), + contentRef: (git: ContentGit, source: string, ref: string) => + git(['ref', ref], source).then((value) => value || undefined), + taskContentRef: (run: string) => 'refs/cindy-make/tasks/' + run + '/integrated', +})); +const roots: string[] = []; +afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); +function fixture() { + const root = mkdtempSync(path.join(os.tmpdir(), 'cindy-build-rollback-')); + roots.push(root); + const store = new CindyMakeHistoryStore(root); + let head = { commit: 'a'.repeat(40), tree: '1'.repeat(40) }; + let dirty = false; + const commits = new Map([[head.commit, head.tree]]); + const refs = new Map(); + const git = vi.fn(async (args) => { + if (args[0] === 'snapshot') return head.tree; + if (args[0] === 'status') return dirty ? ' M keep.txt' : ''; + if (args[0] === 'rev-parse') return args[1] === 'HEAD' ? head.commit : 'cindy-personal'; + if (args[0] === 'ref') return refs.get(args[1]) ?? ''; + if (args[0] === 'update-ref') { + if (args[1] === '-d') refs.delete(args[2]); + else refs.set(args[1], args[2]); + } + if (args[0] === 'reset') head = { commit: args[2], tree: commits.get(args[2])! }; + return ''; + }); + const integrate = (runId: string, id: string, commit: string, tree: string) => { + store.seed({ + runId, + sessionId: runId, + title: runId, + request: runId, + createdAt: 1, + updatedAt: 1, + }); + const receipt: MakeFeatureReceipt = { + id, + action: 'integrate', + at: commits.size, + baselineCommit: head.commit, + beforeTree: head.tree, + commit: commit.repeat(40), + tree: tree.repeat(40), + taskTree: tree.repeat(40), + }; + store.receipt(runId, receipt); + refs.set('refs/cindy-make/tasks/' + runId + '/integrated', receipt.taskTree); + head = { commit: receipt.commit, tree: receipt.tree }; + commits.set(head.commit, head.tree); + return receipt; + }; + const rollback = (isPublishedCommit?: (commit: string) => boolean) => + historyBuildRollback(store, root, isPublishedCommit).prepareRollback(head, git); + return { + store, + git, + integrate, + rollback, + refs, + root, + head: () => head, + dirty: () => { + dirty = true; + }, + advance: () => { + head = { commit: 'f'.repeat(40), tree: '6'.repeat(40) }; + }, + }; +} + +it('withdraws consecutive unpublished integrations but keeps the last generated source and task records', async () => { + const h = fixture(); + const saved = h.integrate('first', 'saved', 'b', '2'); + h.store.version('first', { operationId: saved.id, commit: saved.commit }); + h.integrate('first', 'next-round', 'c', '3'); + h.integrate('second', 'new-task', 'd', '4'); + await h.rollback()(); + expect(h.head()).toEqual({ commit: saved.commit, tree: saved.tree }); + expect(h.store.read('first')?.receipts).toEqual([saved]); + expect(h.store.read('second')?.receipts).toEqual([]); + expect(h.store.list()).toHaveLength(2); + expect(h.refs.get('refs/cindy-make/tasks/first/integrated')).toBe(saved.taskTree); + expect(h.refs.has('refs/cindy-make/tasks/second/integrated')).toBe(false); + expect(h.refs.get('refs/cindy-make/failed-builds/' + 'd'.repeat(40))).toBe('d'.repeat(40)); + expect(h.store.readBuildRollback()).toEqual([]); +}); + +it('resumes cleanup after Git has restored the source but persisting history failed', async () => { + const h = fixture(); + h.integrate('task', 'operation', 'b', '2'); + const real = h.store.rollbackReceipt.bind(h.store); + vi.spyOn(h.store, 'rollbackReceipt') + .mockImplementationOnce(() => { + throw new Error('disk busy'); + }) + .mockImplementation(real); + await expect(h.rollback()()).rejects.toThrow('disk busy'); + expect(h.head().commit).toBe('a'.repeat(40)); + expect(h.store.readBuildRollback()).toHaveLength(1); + const reopened = new CindyMakeHistoryStore(h.root); + await historyBuildRollback(reopened, h.root).recoverRollback(h.git); + expect(reopened.readBuildRollback()).toEqual([]); + expect(reopened.read('task')?.receipts).toEqual([]); + expect(h.git.mock.calls.filter(([args]) => args[0] === 'reset')).toHaveLength(1); +}); + +it.each(['dirty', 'advance'] as const)( + 'preserves source edits made during packaging: %s', + async (change) => { + const h = fixture(); + const receipt = h.integrate('task', 'operation', 'b', '2'); + const rollback = h.rollback(); + h[change](); + await expect(rollback()).rejects.toThrow('Personal source changed'); + expect(h.git.mock.calls.some(([args]) => args[0] === 'reset')).toBe(false); + expect(h.store.read('task')?.receipts).toEqual([receipt]); + expect(h.store.readBuildRollback()).toHaveLength(1); + }, +); + +it('checks saved versions again before touching Git during recovery', async () => { + const h = fixture(); + const receipt = h.integrate('task', 'operation', 'b', '2'); + const rollback = h.rollback(); + h.store.version('task', { operationId: receipt.id, commit: receipt.commit }); + await expect(rollback()).rejects.toThrow('Integration changed'); + expect(h.git.mock.calls.some(([args]) => args[0] === 'reset')).toBe(false); +}); + +it('does not roll past a saved version whose latest integration changed no files', async () => { + const h = fixture(); + h.integrate('first', 'first-operation', 'b', '2'); + const saved = h.integrate('second', 'no-change', 'b', '2'); + h.store.version('second', { operationId: saved.id, commit: saved.commit }); + await h.rollback()(); + expect(h.head().commit).toBe(saved.commit); + expect(h.git).not.toHaveBeenCalled(); +}); + +it('does not roll back a published snapshot whose history registration was interrupted', async () => { + const h = fixture(); + const published = h.integrate('task', 'published-operation', 'b', '2'); + await h.rollback((commit) => commit === published.commit)(); + expect(h.head()).toEqual({ commit: published.commit, tree: published.tree }); + expect(h.store.read('task')?.receipts).toEqual([published]); + expect(h.git).not.toHaveBeenCalled(); +}); diff --git a/apps/desktop/src/main/cindy-make/__tests__/featureHistory.git-integration.test.ts b/apps/desktop/src/main/cindy-make/__tests__/featureHistory.git-integration.test.ts index 303a8c9459..97a2bc0e59 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/featureHistory.git-integration.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/featureHistory.git-integration.test.ts @@ -17,6 +17,7 @@ import { import { makeSourceCheckoutPath } from '../sourcePaths'; import { planFeatureChange } from '../featurePlan'; import { CindyMakeHistoryStore } from '../historyStore'; +import { historyBuildRollback } from '../buildRollback'; import type { MakeFeatureAction } from '../../../shared/cindyMakeHistory'; import type { CindyMakeMergeState } from '../../../shared/cindyMakeMerge'; @@ -116,6 +117,40 @@ async function fixture() { } } +it('restores a failed build to the saved version and can generate the same task changes again', async () => { + const h = await fixture(); + try { + const first = await h.task('aaaa', 'a.txt', 'saved-feature\n'); + const saved = await h.action('aaaa', 'integrate'); + h.store.version('aaaa', { operationId: saved.id, commit: saved.commit! }); + await writeFile(path.join(first.path, 'a.txt'), 'next-round\n'); + h.store.completion('aaaa', { + ...(await collectCindyMakeChanges(h.git, h.userData, first.path)), + id: randomUUID(), + reportedAt: Date.now(), + }); + await h.action('aaaa', 'integrate'); + const second = await h.task('bbbb', 'b.txt', 'new-feature\n'); + const pending = await h.action('bbbb', 'integrate'); + const rollback = historyBuildRollback(h.store, h.source); + await rollback.prepareRollback({ commit: pending.commit!, tree: pending.tree! }, h.git)(); + expect(await h.git(['rev-parse', 'HEAD'])).toBe(saved.commit); + expect(await readFile(path.join(h.source, 'a.txt'), 'utf8')).toBe('saved-feature\n'); + expect(await readFile(path.join(h.source, 'b.txt'), 'utf8')).toBe('base-b\n'); + expect(await readFile(path.join(first.path, 'a.txt'), 'utf8')).toBe('next-round\n'); + expect(await readFile(path.join(second.path, 'b.txt'), 'utf8')).toBe('new-feature\n'); + expect(h.store.read('aaaa')?.receipts).toHaveLength(1); + expect(h.store.read('bbbb')?.receipts).toEqual([]); + expect((await h.action('aaaa', 'integrate')).status).toBe('merged'); + expect((await h.action('bbbb', 'integrate')).status).toBe('merged'); + expect(await readFile(path.join(h.source, 'a.txt'), 'utf8')).toBe('next-round\n'); + expect(await readFile(path.join(h.source, 'b.txt'), 'utf8')).toBe('new-feature\n'); + expect(await h.git(['status', '--porcelain'])).toBe(''); + } finally { + await h.clean(); + } +}, 120000); + it('keeps history and undoable changes after ending a worktree, preserves another feature, and reapplies explicitly', async () => { const h = await fixture(); try { diff --git a/apps/desktop/src/main/cindy-make/__tests__/historyActions.test.ts b/apps/desktop/src/main/cindy-make/__tests__/historyActions.test.ts index cb7e4136d2..e20b6977c3 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/historyActions.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/historyActions.test.ts @@ -35,7 +35,14 @@ describe('history action admission', () => { }, ); it('keeps Open available and adds Continue for verified completed edits', () => { - expect(makeHistoryActions(ready)).toEqual(['open', 'continue', 'test', 'integrate', 'end']); + expect(makeHistoryActions(ready)).toEqual([ + 'open', + 'continue', + 'test', + 'integrate', + 'end', + 'build', + ]); expect(makeHistoryActions({ ...ready, integration: 'unknown' })).toEqual(['open', 'end']); expect(makeHistoryActions({ ...ready, integration: 'unchanged' })).toEqual([ 'open', @@ -54,6 +61,7 @@ describe('history action admission', () => { 'test', 'end', 'revert', + 'build', ]); expect(makeHistoryActions({ ...ready, integration: 'reverted', hasReceipts: true })).toEqual([ 'open', @@ -61,6 +69,7 @@ describe('history action admission', () => { 'test', 'end', 'reapply', + 'build', ]); expect( makeHistoryActions({ @@ -69,7 +78,7 @@ describe('history action admission', () => { hasReceipts: true, newChanges: true, }), - ).toEqual(['open', 'continue', 'test', 'integrate', 'end']); + ).toEqual(['open', 'continue', 'test', 'integrate', 'end', 'build']); expect( makeHistoryActions({ ...ready, @@ -78,7 +87,7 @@ describe('history action admission', () => { integration: 'reverted', hasReceipts: true, }), - ).toEqual(['open', 'reapply']); + ).toEqual(['open', 'reapply', 'build']); }); it('does not offer editing or cleanup again for ended history, but still permits retained undo', () => { expect( @@ -89,9 +98,9 @@ describe('history action admission', () => { integration: 'integrated', hasReceipts: true, }), - ).toEqual(['open', 'revert']); + ).toEqual(['open', 'revert', 'build']); expect(makeHistoryActions({ ...ready, lifecycle: 'ended', workspaceAvailable: false })).toEqual( - ['open', 'integrate'], + ['open', 'integrate', 'build'], ); }); it('replaces mutations with conflict recovery or the specific failed preparation/cleanup action', () => { @@ -107,8 +116,8 @@ describe('history action admission', () => { ]); expect(makeHistoryActions({ ...ready, lifecycle: 'ended', buildFailed: true })).toEqual([ 'open', - 'build', 'integrate', + 'build', ]); expect( makeHistoryActions({ @@ -154,4 +163,14 @@ describe('history action admission', () => { expect(actions).not.toContain('revert'); expect(actions).not.toContain('reapply'); }); + it('offers the same generation action before and after a failed generation, never for unfinished new edits', () => { + expect(makeHistoryActions(ready)).toContain('build'); + expect(makeHistoryActions({ ...ready, buildFailed: true })).toContain('build'); + expect( + makeHistoryActions({ ...ready, integration: 'changed', completed: false, needsBuild: true }), + ).not.toContain('build'); + expect(makeHistoryActions({ ...ready, conflict: true, buildFailed: true })).not.toContain( + 'build', + ); + }); }); diff --git a/apps/desktop/src/main/cindy-make/__tests__/historyRuntime.test.ts b/apps/desktop/src/main/cindy-make/__tests__/historyRuntime.test.ts index 122ff18a44..660d77f72a 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/historyRuntime.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/historyRuntime.test.ts @@ -18,12 +18,16 @@ const h = vi.hoisted(() => ({ artifactPath: vi.fn(), showItem: vi.fn(), build: vi.fn(), - testBuild: undefined as { buildId: string; status: string; stopping?: boolean } | undefined, + testBuild: undefined as + { buildId: string; status: string; stopping?: boolean; sessionId?: string } | undefined, cancelTestBuild: vi.fn(), testStatus: vi.fn(), testUsing: false, + testAction: vi.fn(), + retryMerge: vi.fn(), })); const store = { + readBuildRollback: () => [], readBuild: vi.fn(), saveBuild: vi.fn(), directory: path.join(os.tmpdir(), 'history-runtime-unit-no-io'), @@ -50,6 +54,9 @@ const store = { }, receipt: (id: string, receipt: MakeFeatureReceipt) => h.records.get(id)!.receipts.push(receipt), version: vi.fn(), + hide: vi.fn((id: string) => { + h.records.get(id)!.hiddenAt = 6; + }), }; vi.mock('electron', () => ({ app: { getPath: () => path.join(os.tmpdir(), 'history-runtime-profile') }, @@ -72,16 +79,17 @@ vi.mock('../../localDb/sessionRouteLock.js', () => ({ })); vi.mock('../upstreamMergeRuntime.js', () => ({ integrateMakeHistory: h.merge, - actUpstreamMerge: vi.fn(), + actUpstreamMerge: h.retryMerge, })); vi.mock('../taskManagement.js', () => ({ manageCindyMakeTask: h.end })); vi.mock('../testRuntime.js', () => ({ - actCindyMakeTest: vi.fn(), + actCindyMakeTest: h.testAction, cindyMakeTestController: { hasActiveJobs: () => !!h.testBuild || h.testUsing, isUsingWorkspace: () => h.testUsing, act: h.testStatus, - isBuilding: () => !!h.testBuild, + isBuilding: (sessionId: string) => + !!h.testBuild && (h.testBuild.sessionId ?? 'session') === sessionId, activeBuild: () => h.testBuild, cancelBuild: h.cancelTestBuild, }, @@ -90,6 +98,7 @@ vi.mock('../testRunner.js', () => ({ verifyMakeTestWorkspace: h.verify })); vi.mock('../personalBuild.js', () => ({ buildCindyPersonal: h.build, personalBuildEnvironment: vi.fn(), + personalBuildError: (code: string) => Object.assign(new Error(code), { code }), personalArtifactPath: h.artifactPath, })); vi.mock('../versionStartup.js', () => ({ @@ -196,9 +205,58 @@ beforeEach(() => { cindyMakeCompletion: { reportedAt: 3, commit: 'f'.repeat(40), tree: 'e'.repeat(40) }, }), }, + { + id: 0, + sessionId: 'session', + role: 'user', + clientId: 'first-request', + content: JSON.stringify('First round prompt'), + agentMeta: null, + createdAt: 2, + }, ]; }); describe('history Main admission and owner boundary', () => { + it('associates each completion with the preceding user prompt', async () => { + const item = (await getCindyMakeHistory('aaaa')).items[0]; + expect(item.completions.at(-1)?.prompt).toBe('First round prompt'); + expect(h.records.get('aaaa')?.completions.at(-1)?.prompt).toBe('First round prompt'); + }); + it('skips synthetic UI triggers when associating a completion with its user prompt', async () => { + h.cards.push({ + id: 2, + sessionId: 'session', + role: 'user', + clientId: 'synthetic-trigger', + content: JSON.stringify('[UI_ACTION_TRIGGER] continue the task'), + agentMeta: null, + createdAt: 2.5, + }); + const item = (await getCindyMakeHistory('aaaa')).items[0]; + expect(item.completions.at(-1)?.prompt).toBe('First round prompt'); + }); + it('continues into generation after retrying a failed integration', async () => { + h.state.upstreamMerge = { + id: 'failed-merge', + status: 'failed', + error: 'checksFailed', + hasWorkspace: true, + feature: { runId: 'aaaa', action: 'integrate' }, + }; + h.retryMerge.mockImplementationOnce(async () => { + h.state.upstreamMerge = undefined; + }); + await actCindyMakeHistory('aaaa', 'retry'); + expect(h.retryMerge).toHaveBeenCalledWith({ action: 'resolve' }); + expect(h.testAction).toHaveBeenCalledWith('session', 'complete', 'build'); + }); + it('generates a completed task through the same integration and build controller as its completion card', async () => { + const item = (await getCindyMakeHistory('aaaa')).items[0]; + expect(item.actions).toContain('build'); + await actCindyMakeHistory('aaaa', 'build'); + expect(h.testAction).toHaveBeenCalledWith('session', 'complete', 'build'); + expect(h.build).not.toHaveBeenCalled(); + }); it.each(['starting', 'ready', 'failed', 'stopped'] as const)( 'projects the completion card test receipt: %s', async (status) => { @@ -292,6 +350,24 @@ describe('history Main admission and owner boundary', () => { expect(state.items[0].actions).toContain('integrate'); cindyMakeManager.forgetTask(runId); }); + it('keeps builds and integrations blocked until cancellation finishes even if its directory is gone', async () => { + h.state.upstreamMerge = { + id: 'update', + status: 'failed', + error: 'cancelFailed', + hasWorkspace: false, + cancellationRequested: true, + }; + const blocked = await getCindyMakeHistory('aaaa'); + expect(blocked).toMatchObject({ busy: true, canBuild: false }); + expect(blocked.items[0].actions).not.toContain('integrate'); + await expect(actCindyMakeHistory('aaaa', 'integrate')).rejects.toThrow('unavailable'); + expect(h.merge).not.toHaveBeenCalled(); + h.state.upstreamMerge = { id: 'update', status: 'cancelled', hasWorkspace: false }; + const released = await getCindyMakeHistory('aaaa'); + expect(released).toMatchObject({ busy: false, canBuild: true }); + expect(released.items[0].actions).toContain('integrate'); + }); it('shows and stops a completion-card build by the same identity without marking it interrupted', async () => { h.testBuild = { buildId: 'card-build', status: 'packaging' }; store.readBuild.mockImplementation(() => h.testBuild); @@ -419,7 +495,7 @@ describe('history Main admission and owner boundary', () => { expect((await getCindyMakeHistory('aaaa')).items[0]).toMatchObject({ lifecycle: 'ended', integration: 'unknown', - actions: ['open', 'build'], + actions: ['open'], }); h.exists = false; expect((await getCindyMakeHistory('aaaa')).items[0]).toMatchObject({ @@ -467,14 +543,54 @@ describe('history Main admission and owner boundary', () => { expect((await getCindyMakeHistory('aaaa')).items[0].actions).not.toContain('integrate'); await expect(actCindyMakeHistory('aaaa', 'integrate')).rejects.toThrow(); }); - it('returns busy immediately for a stale cleanup action on the running task', async () => { - h.running = true; - await expect(actCindyMakeHistory('aaaa', 'hide')).rejects.toThrow('busy'); - expect(h.end).not.toHaveBeenCalled(); - }); + it.each(['running', 'testing', 'cleaning'] as const)( + 'rejects a stale cleanup action when the target task becomes %s', + async (activity) => { + expect((await getCindyMakeHistory('aaaa')).items[0].canHide).toBe(true); + if (activity === 'running') h.running = true; + else if (activity === 'testing') h.testUsing = true; + else { + h.busy = true; + h.state.taskActions = { session: { status: 'running' } }; + } + await expect(actCindyMakeHistory('aaaa', 'hide')).rejects.toThrow('busy'); + expect(h.end).not.toHaveBeenCalled(); + expect(store.hide).not.toHaveBeenCalled(); + }, + ); it('keeps cleanup available for an idle history record while another task owns global work', async () => { h.busy = true; expect((await getCindyMakeHistory('aaaa')).items[0].actions).toContain('end'); + const state = await actCindyMakeHistory('aaaa', 'hide'); + expect(h.end).toHaveBeenCalledWith('session', 'end'); + expect(store.hide).toHaveBeenCalledWith('aaaa'); + expect(state).toMatchObject({ busy: true, canBuild: false, items: [] }); + }); + it('cleans an ended history entry while another build continues', async () => { + await getCindyMakeHistory('aaaa'); + const record = h.records.get('aaaa')!; + record.endedAt = 5; + h.rows[0].status = 'deleted'; + h.testBuild = { + buildId: 'other-build', + sessionId: 'other-session', + status: 'packaging', + }; + store.readBuild.mockImplementation(() => h.testBuild); + const item = (await getCindyMakeHistory('aaaa')).items[0]; + expect(item).toMatchObject({ + lifecycle: 'ended', + actions: [], + actionReason: 'busy', + canHide: true, + }); + const state = await actCindyMakeHistory('aaaa', 'hide'); + expect(state).toMatchObject({ busy: true, canBuild: false, items: [], build: h.testBuild }); + expect(store.hide).toHaveBeenCalledWith('aaaa'); + expect(record.completions).toHaveLength(1); + expect(h.end).not.toHaveBeenCalled(); + expect(h.cancelTestBuild).not.toHaveBeenCalled(); + expect(h.merge).not.toHaveBeenCalled(); }); it('never writes captured task data into an owner that changed while the read was pending', async () => { h.current = false; diff --git a/apps/desktop/src/main/cindy-make/__tests__/historyStore.test.ts b/apps/desktop/src/main/cindy-make/__tests__/historyStore.test.ts index aa4468eee4..e968295c7f 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/historyStore.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/historyStore.test.ts @@ -32,6 +32,25 @@ it('preserves real checking stages and known failures while filtering private or store.saveBuild({ status: 'checking' }); expect(store.readBuild()).toEqual({ status: 'checking' }); }); +it('keeps only bounded known build log entries and preparation stages', () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'make-build-log-')); + dirs.push(dir); + const store = new CindyMakeHistoryStore(dir); + store.saveBuild({ + status: 'waiting', + preparationStep: 'environment', + logs: [ + { step: 'environment', at: 1 }, + { step: 'private-output' as never, at: 2 }, + { step: 'ready', at: Number.NaN }, + ...Array.from({ length: 90 }, (_, index) => ({ step: 'packaging' as const, at: index + 3 })), + ], + }); + const build = store.readBuild(); + expect(build?.preparationStep).toBe('environment'); + expect(build?.logs).toHaveLength(80); + expect(build?.logs?.every((entry) => entry.step === 'packaging')).toBe(true); +}); it('keeps ended history, deduplicates operation receipts, and does not confuse build state with a task record', () => { const dir = mkdtempSync(path.join(os.tmpdir(), 'make-history-store-')); dirs.push(dir); diff --git a/apps/desktop/src/main/cindy-make/__tests__/manager.test.ts b/apps/desktop/src/main/cindy-make/__tests__/manager.test.ts index 3df95a48bf..9d340c9b2e 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/manager.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/manager.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { CindyMakeManager } from '../manager.js'; import type { MakeDoctorReport } from '../../../shared/cindyMakeDoctor.js'; +import type { SourcePreparationResult } from '../sourcePreparation.js'; const report = ( runId: string, @@ -33,6 +34,44 @@ describe('CindyMakeManager', () => { expect(run).not.toHaveBeenCalled(); } }); + it.each([false, true])( + 'blocks unfinished cancellation and releases source work once cancelled (clearOnly=%s)', + async (clearOnly) => { + const manager = new CindyMakeManager(); + const merge = { + id: 'merge', + ref: 'main', + upstreamCommit: 'a'.repeat(40), + hasWorkspace: false, + }; + manager.setUpstreamMerge({ + ...merge, + status: 'failed', + error: 'cancelFailed', + cancellationRequested: true, + }); + const result: SourcePreparationResult = { + status: 'ready', + path: '/managed/source', + target: { channel: 'dev', version: '0.0.0-dev', ref: 'main', candidates: [] }, + }; + const run = vi.fn(async () => result); + const input: Parameters[0] = { + root: '/managed', + clearOnly, + signal: new AbortController().signal, + cancelled: () => ({ ...result, status: 'cancelled' }), + onProgress: vi.fn(), + toStatus: ({ status, path }) => ({ status, path }), + run, + }; + await expect(manager.prepareSource(input)).rejects.toMatchObject({ code: 'busy' }); + expect(run).not.toHaveBeenCalled(); + manager.setUpstreamMerge({ ...merge, status: 'cancelled' }); + await expect(manager.prepareSource(input)).resolves.toEqual(result); + expect(run).toHaveBeenCalledOnce(); + }, + ); it('deduplicates one operation, replays progress, and keeps state after completion', async () => { const manager = new CindyMakeManager(); const firstListener = vi.fn(); diff --git a/apps/desktop/src/main/cindy-make/__tests__/personalBuild.git-integration.test.ts b/apps/desktop/src/main/cindy-make/__tests__/personalBuild.git-integration.test.ts index 587dcb2bc4..b50487c7a2 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/personalBuild.git-integration.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/personalBuild.git-integration.test.ts @@ -45,6 +45,8 @@ async function fixture() { await writeFile(path.join(source, '.gitignore'), 'apps/desktop/release/\n'); await git(['add', '.']); await git(['commit', '-s', '-m', 'base']); + // A tag checkout has origin/main but no local main branch. + await git(['update-ref', 'refs/remotes/origin/main', 'HEAD']); await createCindyMakeWorktree(userData, 'run', signal, { processEnvironment: env }); await writeFile(path.join(workingDir, 'feature.txt'), 'task change'); @@ -136,27 +138,37 @@ async function fixture() { } } -it('merges task commits before packaging in personal, preserving integration on failure and avoiding duplicate commits on retry', async () => { +it('rolls back failed generations, preserves concurrent edits and keeps successful retries idempotent', async () => { const h = await fixture(); try { const baseline = await h.git(['rev-parse', 'HEAD']); h.pnpm.mockRejectedValueOnce(new Error('checks failed')); await expect(h.run()).rejects.toMatchObject({ code: 'checksFailed' }); - const integratedCommit = await h.git(['rev-parse', 'HEAD']); - expect(integratedCommit).not.toBe(baseline); - expect(await h.git(['merge-base', '--is-ancestor', h.task.commit, integratedCommit])).toBe(''); - expect(await readFile(path.join(h.source, 'feature.txt'), 'utf8')).toBe('task change'); + const personalCommit = await h.git(['rev-parse', 'HEAD']); + // Existing uncommitted personal edits are saved before integrating the task. + expect(personalCommit).not.toBe(baseline); + expect(await h.git(['show', 'HEAD:personal.txt'])).toBe('existing personal change'); + await expect( + h.git(['merge-base', '--is-ancestor', h.task.commit, personalCommit]), + ).rejects.toThrow(); + await expect(access(path.join(h.source, 'feature.txt'))).rejects.toThrow(); expect(await readFile(path.join(h.workingDir, 'feature.txt'), 'utf8')).toBe('task change'); - expect(await h.git(['rev-parse', 'refs/cindy-make/tasks/run/integrated'])).toBe(h.task.tree); + expect( + await h.git(['for-each-ref', '--format=%(refname)', 'refs/cindy-make/tasks/run/integrated']), + ).toBe(''); + const failedCommit = ( + await h.git(['for-each-ref', '--format=%(objectname)', 'refs/cindy-make/failed-builds/']) + ).split('\n')[0]; + expect(await h.git(['show', failedCommit + ':feature.txt'])).toBe('task change'); h.packageCommand.mockRejectedValueOnce(new Error('packaging failed')); await expect(h.run()).rejects.toThrow('packaging failed'); - expect(await h.git(['rev-parse', 'HEAD'])).toBe(integratedCommit); - expect(await readFile(path.join(h.source, 'feature.txt'), 'utf8')).toBe('task change'); + expect(await h.git(['rev-parse', 'HEAD'])).toBe(personalCommit); + await expect(access(path.join(h.source, 'feature.txt'))).rejects.toThrow(); h.packageCommand.mockImplementationOnce(async (...args) => { await h.pack(args[0], args[1], args[2]); await writeFile(path.join(h.source, 'concurrent.txt'), 'another completed personal change'); }); - await expect(h.run()).rejects.toMatchObject({ code: 'baselineChanged' }); + await expect(h.run()).rejects.toMatchObject({ code: 'cleanupFailed' }); expect(await readFile(path.join(h.source, 'concurrent.txt'), 'utf8')).toBe( 'another completed personal change', ); @@ -265,7 +277,7 @@ it('rejects files edited after the completion snapshot before packaging or integ } }, 90_000); -it('keeps integrated source and task files on cancellation, then retries without losing later edits', async () => { +it('rolls back an interrupted generation and retries without losing task or personal edits', async () => { const h = await fixture(); try { const abort = new AbortController(); @@ -278,9 +290,14 @@ it('keeps integrated source and task files on cancellation, then retries without code: 'interrupted', }); expect(h.packageCommand).not.toHaveBeenCalled(); - expect(await readFile(path.join(h.source, 'feature.txt'), 'utf8')).toBe('task change'); + await expect(access(path.join(h.source, 'feature.txt'))).rejects.toThrow(); + expect(await readFile(path.join(h.source, 'personal.txt'), 'utf8')).toBe( + 'existing personal change', + ); expect(await readFile(path.join(h.workingDir, 'feature.txt'), 'utf8')).toBe('task change'); - expect(await h.git(['rev-parse', 'refs/cindy-make/tasks/run/integrated'])).toBe(h.task.tree); + expect( + await h.git(['for-each-ref', '--format=%(refname)', 'refs/cindy-make/tasks/run/integrated']), + ).toBe(''); await writeFile(path.join(h.workingDir, 'continued.txt'), 'continued after cancellation'); Object.assign(h.task, await collectCindyMakeChanges(h.git, h.userData, h.workingDir)); await h.run(); diff --git a/apps/desktop/src/main/cindy-make/__tests__/personalBuild.test.ts b/apps/desktop/src/main/cindy-make/__tests__/personalBuild.test.ts index 80a84f2984..753fdab036 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/personalBuild.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/personalBuild.test.ts @@ -26,6 +26,8 @@ import type { runSourcePnpm } from '../sourcePnpm'; import type { PtySpawnFn } from '../../terminal/ptyFactory'; import * as versionStore from '../versionStore'; import { CindyMakeManager } from '../manager'; +import { historyBuildRollback } from '../buildRollback'; +import { CindyMakeHistoryStore } from '../historyStore'; vi.mock('../localHistory', () => ({ MAKE_GIT_IDENTITY: [], commitPersonalFiles: async ( @@ -94,6 +96,7 @@ async function fixture() { const abort = new AbortController(); const git = vi.fn(async (_env, args, cwd) => { expect(locked).toBe(true); + if (args[0] === 'merge-base' && args[2] === 'refs/heads/main') return 'd'.repeat(40); if (args[0] === 'worktree' && args[1] === 'add') { buildDir = args[3]; events.push('merge'); @@ -110,6 +113,11 @@ async function fixture() { sourceTree = 'e'.repeat(40); events.push('adopt'); } + if (args[0] === 'reset' && args[1] === '--keep') { + baseline = args[2]; + sourceTree = baseline === 'a'.repeat(40) ? baseline : 'e'.repeat(40); + events.push('rollback'); + } return ''; }); const pnpm = vi.fn(async (_env, args, cwd) => { @@ -157,6 +165,7 @@ async function fixture() { withProject: (operation: () => Promise) => Promise = async (operation) => operation(), personalOnly = false, features?: () => Array<{ runId: string; operationId: string }>, + rollback?: ReturnType, ) => buildCindyPersonal( personalOnly ? { mode: 'personal', userData, completionId: 'history-build' } : task, @@ -177,7 +186,7 @@ async function fixture() { locked = false; } }), - { git, pnpm, verify, packageCommand, features }, + { git, pnpm, verify, packageCommand, features, ...rollback }, ); return { userData, @@ -193,6 +202,10 @@ async function fixture() { abort, baseline: () => baseline, sourceTree: () => sourceTree, + integrate: () => { + baseline = 'f'.repeat(40); + sourceTree = 'e'.repeat(40); + }, locked: () => locked, locks: () => locks, advance: () => { @@ -203,6 +216,83 @@ async function fixture() { } describe('personal source integration and packaging', () => { + it.each(['before-start', 'checks', 'package'] as const)( + 'cleans the runtime integration and its receipt when generation fails at %s', + async (stage) => { + const h = await fixture(); + h.integrate(); + const store = new CindyMakeHistoryStore(path.join(h.userData, 'history')); + store.seed({ + runId: 'run', + sessionId: 'session', + title: 'Feature', + request: 'Feature', + createdAt: 1, + updatedAt: 1, + }); + store.receipt('run', { + id: 'pending-integration', + action: 'integrate', + at: 2, + baselineCommit: 'a'.repeat(40), + beforeTree: 'a'.repeat(40), + commit: 'f'.repeat(40), + tree: 'e'.repeat(40), + taskTree: 'e'.repeat(40), + }); + if (stage === 'before-start') h.abort.abort(); + if (stage === 'checks') h.pnpm.mockRejectedValueOnce(new Error('checks failed')); + if (stage === 'package') + h.packageCommand.mockRejectedValueOnce(personalBuildError('buildFailed')); + await expect( + h.run(undefined, true, undefined, historyBuildRollback(store, h.source)), + ).rejects.toBeDefined(); + expect(h.baseline()).toBe('a'.repeat(40)); + expect(h.sourceTree()).toBe('a'.repeat(40)); + expect(store.read('run')?.receipts).toEqual([]); + expect(store.readBuildRollback()).toEqual([]); + expect(await readFile(path.join(h.task.workingDir, 'keep.txt'), 'utf8')).toBe('editing work'); + }, + ); + it('pins migration checks to inherited official history, even when origin/main is newer', async () => { + const h = await fixture(); + await h.run(); + expect(h.packageCommand.mock.calls[0][3].XDT_MIGRATION_BASE_REF).toBe('d'.repeat(40)); + expect(h.packageCommand.mock.calls[0][3].XDT_MIGRATION_BASE_REF).not.toBe(h.baseline()); + const original = h.git.getMockImplementation()!; + h.git.mockImplementation(async (...args) => + args[1][0] === 'merge-base' && args[1][2] === 'refs/heads/main' ? '' : original(...args), + ); + h.packageCommand.mockClear(); + await expect(h.run()).rejects.toMatchObject({ code: 'changed' }); + expect(h.packageCommand).not.toHaveBeenCalled(); + }); + it('builds a tag checkout without local main using only inherited origin history', async () => { + const h = await fixture(); + const original = h.git.getMockImplementation()!; + h.git.mockImplementation(async (...args) => { + if (args[1][0] === 'show-ref') + throw Object.assign(new Error('local main is absent'), { exitCode: 1 }); + if (args[1][0] === 'merge-base' && args[1][2] === 'refs/remotes/origin/main') + return 'd'.repeat(40); + return original(...args); + }); + await h.run(); + expect(h.packageCommand.mock.calls[0][3].XDT_MIGRATION_BASE_REF).toBe('d'.repeat(40)); + expect(h.packageCommand.mock.calls[0][3].XDT_MIGRATION_BASE_REF).not.toBe(h.baseline()); + }); + it('does not treat an unreadable local reference as a missing branch', async () => { + const h = await fixture(); + const original = h.git.getMockImplementation()!; + h.git.mockImplementation(async (...args) => { + if (args[1][0] === 'show-ref') + throw Object.assign(new Error('reference database unavailable'), { exitCode: 128 }); + return original(...args); + }); + await expect(h.run()).rejects.toMatchObject({ exitCode: 128 }); + expect(h.packageCommand).not.toHaveBeenCalled(); + expect(h.baseline()).toBe('a'.repeat(40)); + }); it('reports each checking step before starting its command and stops at a failed test', async () => { const h = await fixture(); const observed: unknown[] = []; @@ -212,19 +302,19 @@ describe('personal source integration and packaging', () => { }); await expect(h.run()).rejects.toMatchObject({ code: 'checksFailed' }); expect(observed).toEqual([ - { status: 'checking', checkStep: 'dependencies' }, - { status: 'checking', checkStep: 'tests' }, + expect.objectContaining({ status: 'checking', checkStep: 'dependencies' }), + expect.objectContaining({ status: 'checking', checkStep: 'tests' }), ]); expect(h.packageCommand).not.toHaveBeenCalled(); observed.length = 0; h.pnpm.mockImplementation(async () => { observed.push(h.publish.mock.calls.at(-1)?.[0]); }); - await h.run(undefined, true); + await h.run(); expect(observed).toEqual([ - { status: 'checking', checkStep: 'dependencies' }, - { status: 'checking', checkStep: 'tests' }, - { status: 'checking', checkStep: 'types' }, + expect.objectContaining({ status: 'checking', checkStep: 'dependencies' }), + expect.objectContaining({ status: 'checking', checkStep: 'tests' }), + expect.objectContaining({ status: 'checking', checkStep: 'types' }), ]); }); it('packages already integrated source after its editing directory is gone, without another merge or a synthetic task', async () => { @@ -314,7 +404,7 @@ describe('personal source integration and packaging', () => { 'cancel', 'publishCancel', ] as const)( - 'preserves integrated files but publishes no artifact on %s failure', + 'restores unpublished integrations, preserves task files and publishes no artifact on %s failure', async (failure) => { const h = await fixture(); const original = h.packageCommand.getMockImplementation()!; @@ -347,8 +437,9 @@ describe('personal source integration and packaging', () => { }); await expect(h.run()).rejects.toBeDefined(); expect(h.events).toContain('adopt'); - expect(h.sourceTree()).toBe('e'.repeat(40)); - expect(h.baseline()).toBe((failure === 'baseline' ? 'c' : 'f').repeat(40)); + expect(h.sourceTree()).toBe((failure === 'baseline' ? 'e' : 'a').repeat(40)); + expect(h.baseline()).toBe((failure === 'baseline' ? 'c' : 'a').repeat(40)); + expect(h.events.includes('rollback')).toBe(failure !== 'baseline'); expect(await readFile(path.join(h.task.workingDir, 'keep.txt'), 'utf8')).toBe('editing work'); await expect(access(h.buildDir())).rejects.toThrow(); await expect(access(h.source)).resolves.toBeUndefined(); @@ -359,7 +450,7 @@ describe('personal source integration and packaging', () => { }, ); - it('finishes integration and its receipt on cancellation, then stops before checks and packaging', async () => { + it('rolls back an adoption cancelled mid-flight before checks or packaging', async () => { const h = await fixture(); const original = h.git.getMockImplementation()!; h.git.mockImplementation(async (...args) => { @@ -370,8 +461,8 @@ describe('personal source integration and packaging', () => { return original(...args); }); await expect(h.run()).rejects.toBeDefined(); - expect(h.baseline()).toBe('f'.repeat(40)); - expect(h.sourceTree()).toBe('e'.repeat(40)); + expect(h.baseline()).toBe('a'.repeat(40)); + expect(h.sourceTree()).toBe('a'.repeat(40)); expect( h.git.mock.calls.some( ([, args]) => args[0] === 'update-ref' && args[1].endsWith('/integrated'), @@ -434,7 +525,7 @@ describe('personal source integration and packaging', () => { expect(publishVersion).toHaveBeenCalledWith(h.userData, id); await expect(access(retainedDirectory)).resolves.toBeUndefined(); } - expect(h.sourceTree()).toBe('e'.repeat(40)); + expect(h.sourceTree()).toBe((cancel ? 'a' : 'e').repeat(40)); await expect(access(h.source)).resolves.toBeUndefined(); await expect(access(h.task.workingDir)).resolves.toBeUndefined(); }, @@ -461,7 +552,7 @@ describe('personal source integration and packaging', () => { const run = h.run((operation) => manager.withProject(root, operation)); await entered; const update = vi.fn(async () => { - expect(h.publish).toHaveBeenLastCalledWith({ status: 'publishing' }); + expect(h.publish).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'publishing' })); expect(h.locked()).toBe(false); await expect(access(h.buildDir())).rejects.toThrow(); }); @@ -547,7 +638,7 @@ describe('packaging process', () => { }; const spawn = vi.fn(() => child) as unknown as PtySpawnFn; const abort = new AbortController(); - const run = () => + const run = (extraEnv: NodeJS.ProcessEnv = {}) => runPersonalPackageCommand( process.execPath, ['package.js'], @@ -558,6 +649,7 @@ describe('packaging process', () => { ELECTRON_RUN_AS_NODE: '1', NODE_OPTIONS: '--require private-hook', OPENAI_API_KEY: 'secret', + ...extraEnv, }, abort.signal, spawn, @@ -573,6 +665,16 @@ describe('packaging process', () => { h.exit(0); await expect(run).resolves.toBeUndefined(); }); + it('forwards only a pinned migration commit through the clean package environment', async () => { + const h = processFixture(); + const run = h.run({ XDT_MIGRATION_BASE_REF: 'd'.repeat(40) }); + expect(vi.mocked(h.spawn).mock.calls[0][2].env?.XDT_MIGRATION_BASE_REF).toBe('d'.repeat(40)); + h.exit(0); + await run; + const invalid = processFixture(); + expect(() => invalid.run({ XDT_MIGRATION_BASE_REF: 'HEAD' })).toThrow('changed'); + expect(invalid.spawn).not.toHaveBeenCalled(); + }); it('cancels the owned process and bounds a hung package build', async () => { vi.useFakeTimers(); const h = processFixture(); diff --git a/apps/desktop/src/main/cindy-make/__tests__/remoteProvider.test.ts b/apps/desktop/src/main/cindy-make/__tests__/remoteProvider.test.ts index db53f2d7a5..c0d614b644 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/remoteProvider.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/remoteProvider.test.ts @@ -87,10 +87,19 @@ describe('portable Cindy Make cards', () => { it('locks editing during the shared build and identifies the exact build to stop', () => { const state = snapshot(); - state.sharedBuild = { buildId: 'build', status: 'checking', checkStep: 'dependencies' }; + state.sharedBuild = { + buildId: 'build', + status: 'checking', + checkStep: 'dependencies', + logs: [{ step: 'merging', at: 1 }, { step: 'checking-dependencies', at: 2 }], + }; const card = project(state); expect(card.actions?.slice(0, 3).every((action) => action.disabled)).toBe(true); expect(card.actions?.at(-1)).toMatchObject({ id: 'build:build:stop', disabled: false }); + expect(card.blocks?.[0].fallbackMarkdown).toContain(en.cindyMake.personal.buildLog.title); + expect(card.blocks?.[0].fallbackMarkdown).toContain( + en.cindyMake.personal.buildLog.steps['checking-dependencies'], + ); state.sharedBuild.stopping = true; expect(project(state).actions?.at(-1)?.disabled).toBe(true); }); diff --git a/apps/desktop/src/main/cindy-make/__tests__/sourcePreparation.test.ts b/apps/desktop/src/main/cindy-make/__tests__/sourcePreparation.test.ts index abb11f6230..a477075f30 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/sourcePreparation.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/sourcePreparation.test.ts @@ -220,7 +220,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if (args[0] === 'ls-remote') return '0123456789abcdef refs/heads/main'; if (args[0] === 'remote') return CINDY_SOURCE_REPOSITORY; if (args[0] === 'branch') return 'cindy-personal'; @@ -510,45 +511,56 @@ describe('Source and dependency preparation', () => { unsubscribe(); }); - it('hydrates Git details from an existing checkout when an old status file lacks them', async () => { - const sourcePath = path.join(root, 'source'); - await createExistingCheckout(sourcePath); - await writeFile( - path.join(root, 'source-status.json'), - JSON.stringify({ + it.each(['cindy-personal', 'main', 'HEAD'])( + 'hydrates personal SHA and counts from Git with the checkout on %s', + async (currentBranch) => { + const sourcePath = path.join(root, 'source'); + await createExistingCheckout(sourcePath); + await writeFile( + path.join(root, 'source-status.json'), + JSON.stringify({ + status: 'ready', + path: sourcePath, + branch: 'cindy-personal', + ref: 'main', + commit: 'a'.repeat(40), + baseCommit: 'b'.repeat(40), + }), + ); + const env = { processEnvironment: () => ({}) } as MakeToolchainEnvironment; + vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); + if (args.includes('refs/heads/cindy-personal^{commit}')) return 'd'.repeat(40); + if (args.join(' ') === 'rev-parse HEAD') return 'e'.repeat(40); + if (args[0] === 'merge-base') return 'b'.repeat(40); + if (args[0] === 'rev-parse' && args[1] === '--verify') return 'c'.repeat(40); + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return currentBranch; + if (args[0] === 'rev-list') return '0\t2'; + throw new Error('not available'); + }); + await expect(readCurrentCindySourceStatus(root, env)).resolves.toMatchObject({ status: 'ready', - path: sourcePath, branch: 'cindy-personal', - ref: 'main', - commit: 'a'.repeat(40), + commit: 'd'.repeat(40), baseCommit: 'b'.repeat(40), - }), - ); - const env = { processEnvironment: () => ({}) } as MakeToolchainEnvironment; - vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); - if (args[0] === 'merge-base') return 'b'.repeat(40); - if (args[0] === 'rev-parse' && args[1] === '--verify') return 'c'.repeat(40); - if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'cindy-personal'; - if (args[0] === 'rev-list') return '0\t2'; - throw new Error('not available'); - }); - await expect(readCurrentCindySourceStatus(root, env)).resolves.toMatchObject({ - status: 'ready', - branch: 'cindy-personal', - baseCommit: 'b'.repeat(40), - mainCommit: 'c'.repeat(40), - currentBranch: 'cindy-personal', - }); - await expect(readCindySourceStatus(root)).resolves.toMatchObject({ - status: 'ready', - }); - await expect(readCindySourceStatus(root)).resolves.not.toHaveProperty('mainCommit'); - expect(vi.mocked(runSourceGit).mock.calls.some(([, args]) => args[0] === 'fetch')).toBe(false); - expect(vi.mocked(runSourceGit).mock.calls.some(([, args]) => args[0] === 'checkout')).toBe( - false, - ); - }); + mainCommit: 'c'.repeat(40), + currentBranch: currentBranch === 'HEAD' ? null : currentBranch, + personalAhead: 0, + personalBehind: 2, + }); + await expect(readCindySourceStatus(root)).resolves.toMatchObject({ + status: 'ready', + }); + await expect(readCindySourceStatus(root)).resolves.not.toHaveProperty('mainCommit'); + expect(vi.mocked(runSourceGit).mock.calls.some(([, args]) => args[0] === 'fetch')).toBe( + false, + ); + expect(vi.mocked(runSourceGit).mock.calls.some(([, args]) => args[0] === 'checkout')).toBe( + false, + ); + }, + ); it('does not invent a personal source version when the legacy status has no baseline', async () => { const sourcePath = path.join(root, 'source'); @@ -559,8 +571,9 @@ describe('Source and dependency preparation', () => { ); const env = { processEnvironment: () => ({}) } as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); - if (args[0] === 'rev-parse' && args[1] === 'HEAD') return 'a'.repeat(40); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); + if (args.includes('refs/heads/cindy-personal^{commit}')) return 'a'.repeat(40); if (args[0] === 'rev-parse' && args[1] === '--verify') return 'b'.repeat(40); if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') return 'cindy-personal'; throw new Error('not available'); @@ -575,6 +588,23 @@ describe('Source and dependency preparation', () => { }); }); + it('keeps a missing personal branch unknown instead of displaying another branch HEAD', async () => { + const sourcePath = path.join(root, 'source'); + await createExistingCheckout(sourcePath); + const env = { processEnvironment: () => ({}) } as MakeToolchainEnvironment; + vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { + if (args.includes('refs/heads/main^{commit}') || args.join(' ') === 'rev-parse HEAD') + return 'c'.repeat(40); + if (args.join(' ') === 'rev-parse --abbrev-ref HEAD') return 'main'; + throw new Error('missing ref'); + }); + const status = await readCurrentCindySourceStatus(root, env); + expect(status.mainCommit).toBe('c'.repeat(40)); + expect(status.commit).toBeUndefined(); + expect(status.personalAhead).toBeUndefined(); + expect(status.personalBehind).toBeUndefined(); + }); + it.each(['missing', 'failed', 'timeout'] as const)( 'reports unavailable Git (%s) without accessing the remote', async (status) => { @@ -606,7 +636,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); switch (args[0]) { case 'ls-remote': return '0123456789abcdef\trefs/heads/main'; @@ -665,7 +696,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if ( (missing === 'main' && args.includes('refs/heads/main^{commit}')) || (missing === 'origin/main' && args.includes('refs/remotes/origin/main^{commit}')) || @@ -783,7 +815,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if (args[0] === 'ls-remote') return '0123456789abcdef\trefs/heads/main'; if (args[0] === 'remote') return CINDY_SOURCE_REPOSITORY; if (args[0] === 'for-each-ref') return 'refs/heads/main'; @@ -809,7 +842,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if (args[0] === 'ls-remote') return '0123456789abcdef\trefs/heads/main'; if (args[0] === 'clone') { await expect(access(sourcePath)).rejects.toThrow(); @@ -837,7 +871,8 @@ describe('Source and dependency preparation', () => { processEnvironment: () => ({}), } as unknown as MakeToolchainEnvironment; vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if (args[0] === 'ls-remote') return '0123456789abcdef\trefs/heads/main'; if (args[0] === 'remote') return CINDY_SOURCE_REPOSITORY; if (args[0] === 'for-each-ref') return 'refs/heads/main'; @@ -927,7 +962,8 @@ describe('Source and dependency preparation', () => { releaseFetch = resolve; }); vi.mocked(runSourceGit).mockImplementation(async (_env, args) => { - if (args.includes('refs/cindy-make/personal-upstream^{commit}')) throw new Error('no file integration yet'); + if (args.includes('refs/cindy-make/personal-upstream^{commit}')) + throw new Error('no file integration yet'); if (args[0] === 'ls-remote') return '0123456789abcdef\trefs/heads/main'; if (args[0] === 'remote') return CINDY_SOURCE_REPOSITORY; if (args[0] === 'fetch') { diff --git a/apps/desktop/src/main/cindy-make/__tests__/testController.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testController.test.ts index 26025953a4..fb59c47040 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/testController.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/testController.test.ts @@ -92,6 +92,69 @@ function harness(initial: Partial = {}) { afterEach(() => vi.useRealTimers()); describe('Main-owned Cindy Make test lifecycle', () => { + it.each(['continue', 'build', 'stop-for-build'] as const)( + 'bounds a missing stop receipt for %s without releasing the live workspace', + async (action) => { + vi.useFakeTimers(); + const h = harness(); + h.stop.mockImplementation(() => {}); + await h.controller.act('session', 'completion', 'start'); + h.ready.resolve(); + await vi.waitFor(() => expect(h.meta().test?.status).toBe('ready')); + const pending = + action === 'stop-for-build' + ? h.controller.stopTestForBuild('session') + : h.controller.act('session', 'completion', action); + const failed = expect(pending).rejects.toMatchObject({ code: 'stopFailed' }); + await vi.advanceTimersByTimeAsync(10_000); + await failed; + expect(h.leased()).toBe(true); + expect(h.controller.isUsingSession('session')).toBe(true); + expect(h.meta().continuedAt).toBeUndefined(); + expect(h.build).not.toHaveBeenCalled(); + // A retry may succeed once the test window really exits; no false exit receipt. + const continued = h.controller.act('session', 'completion', 'continue'); + h.closed.resolve(); + await continued; + expect(h.leased()).toBe(false); + expect(h.meta().continuedAt).toBe(123); + }, + ); + + it('waits for test shutdown and temporary cleanup before starting a personal build', async () => { + const h = harness(); + h.stop.mockImplementation(() => {}); + await h.controller.act('session', 'completion', 'start'); + h.ready.resolve(); + await vi.waitFor(() => expect(h.meta().test?.status).toBe('ready')); + const pending = h.controller.act('session', 'completion', 'build'); + await vi.waitFor(() => expect(h.stop).toHaveBeenCalled()); + expect(h.build).not.toHaveBeenCalled(); + expect(h.leased()).toBe(true); + h.closed.resolve(); + await pending; + expect(h.build).toHaveBeenCalledOnce(); + h.artifact.resolve(installer); + await vi.waitFor(() => expect(h.controller.hasActiveJobs()).toBe(false)); + }); + + it('awaits test cleanup when the host quits', async () => { + const h = harness(); + h.stop.mockImplementation(() => {}); + await h.controller.act('session', 'completion', 'start'); + h.ready.resolve(); + await vi.waitFor(() => expect(h.meta().test?.status).toBe('ready')); + const done = vi.fn(); + const shutdown = h.controller.stopAllAndWait().then(done); + await Promise.resolve(); + expect(h.stop).toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + h.closed.resolve(); + await shutdown; + expect(h.leased()).toBe(false); + expect(done).toHaveBeenCalledOnce(); + }); + it('blocks Continue Editing during startup, then allows it once the test is ready', async () => { const h = harness(); await h.controller.act('session', 'completion', 'start'); @@ -315,6 +378,15 @@ describe('personal build completion choices', () => { await h.controller.act('session', 'completion', 'open-build'); expect(h.openBuild).toHaveBeenCalledOnce(); }); + it('records visible build stages without persisting process output', async () => { + const h = harness(); + await h.controller.act('session', 'completion', 'build'); + await vi.waitFor(() => expect(h.meta().personal?.status).toBe('packaging')); + expect(h.meta().personal?.logs?.map((entry) => entry.step)).toEqual(['packaging']); + h.artifact.resolve(installer); + await vi.waitFor(() => expect(h.meta().personal?.status).toBe('ready')); + expect(h.meta().personal?.logs?.map((entry) => entry.step)).toEqual(['packaging', 'ready']); + }); it('closes a running test before building the personal installer', async () => { const h = harness(); await h.controller.act('session', 'completion', 'start'); diff --git a/apps/desktop/src/main/cindy-make/__tests__/testProcess.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testProcess.test.ts new file mode 100644 index 0000000000..b12a57a11f --- /dev/null +++ b/apps/desktop/src/main/cindy-make/__tests__/testProcess.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { stopMakeTestProcess } from '../testProcess'; + +describe('Make test process cleanup', () => { + it.each(['darwin', 'linux'] as const)('stops the private PTY group on %s', (platform) => { + const child = { pid: 4242, kill: vi.fn() }; + const kill = vi.fn(() => true); + stopMakeTestProcess(child, platform, kill); + expect(kill).toHaveBeenCalledWith(-4242, 'SIGKILL'); + expect(child.kill).not.toHaveBeenCalled(); + }); + + it('lets node-pty close its Windows console tree', () => { + const child = { pid: 4242, kill: vi.fn() }; + const kill = vi.fn(); + stopMakeTestProcess(child, 'win32', kill); + expect(child.kill).toHaveBeenCalledOnce(); + expect(kill).not.toHaveBeenCalled(); + }); + + it('falls back to the PTY process when the process group is missing', () => { + const child = { pid: 4242, kill: vi.fn() }; + const kill = vi.fn(); + kill.mockImplementationOnce(() => { + throw Object.assign(new Error('gone'), { code: 'ESRCH' }); + }); + stopMakeTestProcess(child, 'darwin', kill); + expect(child.kill).toHaveBeenCalledOnce(); + }); + + it('falls back to the PTY process when signalling the group is denied', () => { + const child = { pid: 4242, kill: vi.fn() }; + const kill = vi.fn(); + kill.mockImplementationOnce(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }); + }); + stopMakeTestProcess(child, 'darwin', kill); + expect(child.kill).toHaveBeenCalledOnce(); + }); + + it('surfaces a permission failure when both group and process stop fail', () => { + const child = { + pid: 4242, + kill: vi.fn(() => { + throw Object.assign(new Error('leaf-denied'), { code: 'EPERM' }); + }), + }; + const kill = vi.fn(() => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }); + }); + expect(() => stopMakeTestProcess(child, 'darwin', kill)).toThrow('denied'); + expect(child.kill).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/src/main/cindy-make/__tests__/testRunner.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testRunner.test.ts index adf702dd4f..b2e4912a1b 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/testRunner.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/testRunner.test.ts @@ -3,7 +3,14 @@ import os from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { IPty } from 'node-pty'; import type { PtySpawnFn } from '../../terminal/ptyFactory'; -const h = vi.hoisted(() => ({ links: new Set(), files: new Set(), git: vi.fn() })); +const h = vi.hoisted(() => ({ + links: new Set(), + files: new Set(), + git: vi.fn(), + createTemp: vi.fn(), + cleanTemp: vi.fn(), +})); +vi.mock('../testTempDirectory.js', () => ({ createMakeTestTempDirectory: h.createTemp })); vi.mock('node:fs/promises', () => ({ lstat: async (file: string) => ({ isSymbolicLink: () => h.links.has(file), @@ -23,7 +30,7 @@ const task = { runId: 'abcd-test', commit: 'a'.repeat(40), }; -function processHarness() { +async function processHarness() { let data: (value: string) => void = () => {}; let exit: () => void = () => {}; const kill = vi.fn(() => exit()); @@ -44,7 +51,7 @@ function processHarness() { const controller = new AbortController(); const progress = vi.fn(); const diagnostics = vi.fn(); - const process = launchMakeTest( + const process = await launchMakeTest( task, { node: path.join(profile, 'node'), pnpm: path.join(profile, 'tools', 'pnpm.cmd') }, { PATH: '/tools', XDT_USER_DATA_DIR: '/host', npm_execpath: '/host/pnpm.cjs' }, @@ -90,6 +97,11 @@ beforeEach(() => { h.links.clear(); h.files.clear(); h.git.mockReset(); + h.cleanTemp.mockReset().mockResolvedValue(undefined); + h.createTemp.mockReset().mockResolvedValue({ + directory: path.join(profile, 'launch-temporary'), + clean: h.cleanTemp, + }); h.files.add(path.join(task.workingDir, '.git')); h.files.add(path.join(task.workingDir, 'scripts', 'desktop-restart-runner.mjs')); h.git.mockImplementation(async (_env, args) => { @@ -103,8 +115,107 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()); describe('isolated Make test runner', () => { + it('keeps persistent isolation stable but directs disposable files to a per-launch directory', async () => { + const first = await processHarness(); + const secondDirectory = path.join(profile, 'another-launch'); + h.createTemp.mockResolvedValueOnce({ directory: secondDirectory, clean: h.cleanTemp }); + const second = await processHarness(); + expect(first.spawn.mock.calls[0][1]).toEqual(second.spawn.mock.calls[0][1]); + expect(first.spawn.mock.calls[0][2].env).toMatchObject({ + TMPDIR: path.join(profile, 'launch-temporary'), + TMP: path.join(profile, 'launch-temporary'), + TEMP: path.join(profile, 'launch-temporary'), + }); + expect(second.spawn.mock.calls[0][2].env?.TEMP).toBe(secondDirectory); + first.exit(); + second.exit(); + await Promise.all([first.process.closed, second.process.closed]); + }); + + it.each(['exit', 'stop', 'failure'] as const)( + 'waits for cleanup after %s before releasing the process', + async (mode) => { + let cleaned!: () => void; + h.cleanTemp.mockImplementationOnce( + () => + new Promise((resolve) => { + cleaned = resolve; + }), + ); + const run = await processHarness(); + const closed = vi.fn(); + void run.process.closed.then(closed); + if (mode === 'failure') run.emit('DESKTOP_DEV_VERDICT=failed\r\ncode=STARTUP_FAILED\r\n'); + else { + run.emit(run.verdict()); + await run.process.ready; + if (mode === 'stop') run.process.stop(); + else run.exit(); + } + expect(h.cleanTemp).toHaveBeenCalledOnce(); + expect(closed).not.toHaveBeenCalled(); + run.process.stop(); + expect(h.cleanTemp).toHaveBeenCalledOnce(); + cleaned(); + await run.process.closed; + expect(closed).toHaveBeenCalledOnce(); + }, + ); + + it('cleans a cancelled preparation before any process is spawned', async () => { + const controller = new AbortController(); + h.createTemp.mockImplementationOnce(async () => { + controller.abort(); + return { directory: path.join(profile, 'cancelled'), clean: h.cleanTemp }; + }); + const spawn = vi.fn(); + await expect( + launchMakeTest(task, { node: 'node', pnpm: 'pnpm' }, {}, 'global', controller.signal, spawn), + ).rejects.toBeTruthy(); + expect(spawn).not.toHaveBeenCalled(); + expect(h.cleanTemp).toHaveBeenCalledOnce(); + }); + + it('cleans an unsuccessful spawn and reports a bounded cleanup failure without file contents', async () => { + const spawn = vi.fn(() => { + throw new Error('spawn failed'); + }); + await expect( + launchMakeTest( + task, + { node: 'node', pnpm: 'pnpm' }, + {}, + 'global', + new AbortController().signal, + spawn, + ), + ).rejects.toThrow('spawn failed'); + expect(h.cleanTemp).toHaveBeenCalledOnce(); + h.cleanTemp.mockRejectedValueOnce(new Error('private path and contents')); + const run = await processHarness(); + run.exit(); + await run.process.closed; + expect(run.diagnostics).toHaveBeenCalledWith( + expect.objectContaining({ event: 'failed', reason: 'cleanupFailed' }), + ); + expect(JSON.stringify(run.diagnostics.mock.calls)).not.toMatch(/private|contents/); + }); + + it('does not mistake a failed stop for a confirmed process exit', async () => { + const run = await processHarness(); + run.kill.mockImplementationOnce(() => { + throw new Error('stop failed'); + }); + run.process.stop(); + expect(h.cleanTemp).not.toHaveBeenCalled(); + expect(run.diagnostics).toHaveBeenCalledWith(expect.objectContaining({ reason: 'stopFailed' })); + run.exit(); + await run.process.closed; + expect(h.cleanTemp).toHaveBeenCalledOnce(); + }); + it('records a wrapper failure code across chunks without raw output or free-form messages', async () => { - const h = processHarness(); + const h = await processHarness(); h.emit( 'compiler output with private data\r\nDESKTOP_DEV_STEP=launching\r\nDESKTOP_DEV_VERDICT=failed\r\n', ); @@ -125,11 +236,11 @@ describe('isolated Make test runner', () => { }); it('bounds incomplete failure verdicts and rejects unknown diagnostic codes', async () => { vi.useFakeTimers(); - const h = processHarness(); + const h = await processHarness(); h.emit('DESKTOP_DEV_VERDICT=failed\r\n'); await vi.advanceTimersByTimeAsync(250); await expect(h.process.ready).rejects.toMatchObject({ code: 'launchFailed' }); - const next = processHarness(); + const next = await processHarness(); next.emit('DESKTOP_DEV_VERDICT=failed\r\ncode=PRIVATE_SECRET\r\n'); await expect(next.process.ready).rejects.toMatchObject({ code: 'launchFailed' }); expect(next.diagnostics).toHaveBeenCalledWith( @@ -138,7 +249,7 @@ describe('isolated Make test runner', () => { expect(JSON.stringify(next.diagnostics.mock.calls)).not.toContain('PRIVATE_SECRET'); }); it('reports bounded known steps across PTY chunks and ignores raw output and late progress', async () => { - const h = processHarness(); + const h = await processHarness(); h.emit('DESKTOP_DEV_ST'); h.emit('EP=dependencies\r\nDESKTOP_DEV_STEP=dependencies\r\n'); h.emit('DESKTOP_DEV_STEP=/private/path\r\ncompiler output\r\n'); @@ -152,7 +263,7 @@ describe('isolated Make test runner', () => { await h.process.closed; }); it('supports the fixed stage prefixes from older source checkouts', async () => { - const h = processHarness(); + const h = await processHarness(); h.emit('[ensure-deps] checking\r\n[ensure-deps] done\r\n'); h.emit('[ensure-dev-runtime-assets] checking\r\n==> Starting desktop remote dev...\r\n'); expect(h.progress.mock.calls).toEqual([['dependencies'], ['assets'], ['launching']]); @@ -199,7 +310,7 @@ describe('isolated Make test runner', () => { ).toEqual({ PATH: '/tools', HOME: '/user', TERM: 'xterm-256color', FORCE_COLOR: '0' }); }); it('uses the existing wrapper, explicit isolation and a real PTY without a shell command', async () => { - const h = processHarness(); + const h = await processHarness(); expect(h.spawn.mock.calls[0][1]).toEqual([ path.join(task.workingDir, 'scripts', 'desktop-restart-runner.mjs'), '--wait-ready', @@ -212,6 +323,7 @@ describe('isolated Make test runner', () => { expect(h.spawn.mock.calls[0][2].env?.npm_execpath).toBe( path.join(profile, 'tools', 'pnpm.cmd'), ); + expect(h.spawn.mock.calls[0][2].env?.XDT_CINDY_MAKE_TEST).toBe('1'); const text = h.verdict(); h.emit(text.slice(0, 17)); h.emit(text.slice(17)); @@ -222,24 +334,24 @@ describe('isolated Make test runner', () => { it.each([{ root: profile }, { commit: 'b'.repeat(40) }, { mode: 'shared' }, { region: 'cn' }])( 'rejects a ready verdict for the wrong launch %j', async (extra) => { - const h = processHarness(); + const h = await processHarness(); h.emit(h.verdict(extra)); await expect(h.process.ready).rejects.toMatchObject({ code: 'launchFailed' }); expect(h.kill).toHaveBeenCalledOnce(); }, ); it('fails on an early exit and aborts only its own process', async () => { - const h = processHarness(); + const h = await processHarness(); h.exit(); await expect(h.process.ready).rejects.toMatchObject({ code: 'launchFailed' }); - const next = processHarness(); + const next = await processHarness(); next.controller.abort(); await expect(next.process.ready).rejects.toMatchObject({ code: 'interrupted' }); expect(next.kill).toHaveBeenCalledOnce(); }); it('bounds startup even if the wrapper never returns a verdict', async () => { vi.useFakeTimers(); - const h = processHarness(); + const h = await processHarness(); await vi.advanceTimersByTimeAsync(25 * 60_000); await expect(h.process.ready).rejects.toMatchObject({ code: 'timeout' }); expect(h.kill).toHaveBeenCalledOnce(); diff --git a/apps/desktop/src/main/cindy-make/__tests__/testRuntime.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testRuntime.test.ts index cda17d3730..949c43b9ca 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/testRuntime.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/testRuntime.test.ts @@ -19,6 +19,10 @@ const h = vi.hoisted(() => ({ artifactPath: vi.fn(), showItem: vi.fn(), rememberOriginal: vi.fn(async () => {}), + recoverBuild: vi.fn(async () => {}), + rollbackGuard: undefined as ((commit: string) => boolean) | undefined, + publishedCommit: vi.fn(() => true), + pendingRollback: false, historyIntegrate: vi.fn(async () => ({ items: [{ runId: 'run', integration: 'integrated' }] })), history: vi.fn(async () => ({ items: [{ runId: 'run', integration: 'integrated' }] })), })); @@ -28,11 +32,22 @@ vi.mock('../historyOwner.js', () => ({ version: vi.fn(), list: () => [], saveBuild: h.saveBuild, + readBuildRollback: () => (h.pendingRollback ? [{}] : []), }), })); vi.mock('../historyRuntime.js', () => ({ getCindyMakeHistory: h.history, actCindyMakeHistory: h.historyIntegrate, + recoverHistoryBuildRollback: h.recoverBuild, +})); +vi.mock('../buildRollback.js', () => ({ + historyBuildRollback: (...args: [unknown, unknown, (commit: string) => boolean]) => { + h.rollbackGuard = args[2]; + return { prepareRollback: vi.fn(), recoverRollback: vi.fn() }; + }, +})); +vi.mock('../versionStore.js', () => ({ + hasPublishedPersonalVersionCommit: h.publishedCommit, })); vi.mock('../versionStartup.js', () => ({ rememberOriginalVersion: h.rememberOriginal, @@ -142,8 +157,10 @@ beforeEach(() => { vi.clearAllMocks(); h.verify.mockReset().mockResolvedValue(undefined); h.build.mockReset().mockResolvedValue(installer); + h.recoverBuild.mockReset().mockResolvedValue(undefined); h.artifactPath.mockReset().mockResolvedValue(path.join(os.tmpdir(), 'installer.exe')); h.current = true; + h.pendingRollback = false; h.laterUser = false; h.profile = path.join(os.tmpdir(), 'make-runtime-unit'); h.probe.mockReset().mockImplementation(async (command) => ({ @@ -171,6 +188,48 @@ beforeEach(() => { afterEach(() => cindyMakeTestController.stopAll()); describe('Cindy Make test IPC ownership and persistence', () => { + it('reports a stop failure without inspecting or modifying personal source', async () => { + const stop = vi + .spyOn(cindyMakeTestController, 'stopTestForBuild') + .mockRejectedValueOnce(Object.assign(new Error('stopFailed'), { code: 'stopFailed' })); + try { + await expect(actCindyMakeTest('session', 'completion', 'build')).rejects.toThrow( + 'stopFailed', + ); + expect(h.history).not.toHaveBeenCalled(); + expect(h.historyIntegrate).not.toHaveBeenCalled(); + expect(h.build).not.toHaveBeenCalled(); + } finally { + stop.mockRestore(); + } + }); + + it('stops the running test and waits for cleanup before inspecting history or building', async () => { + let clean!: () => void; + const closed = new Promise((resolve) => { + clean = resolve; + }); + const stop = vi.fn(); + h.launch.mockResolvedValueOnce({ ready: Promise.resolve(), closed, stop }); + await actCindyMakeTest('session', 'completion', 'start'); + await vi.waitFor(() => + expect(JSON.parse(String(h.card.agentMeta)).cindyMakeCompletion.test.status).toBe('ready'), + ); + const building = actCindyMakeTest('session', 'completion', 'build'); + try { + await vi.waitFor(() => expect(stop).toHaveBeenCalled()); + expect(h.history).not.toHaveBeenCalled(); + expect(h.historyIntegrate).not.toHaveBeenCalled(); + expect(h.build).not.toHaveBeenCalled(); + } finally { + clean(); + } + await building; + await vi.waitFor(() => expect(h.build).toHaveBeenCalledOnce()); + expect(h.history).toHaveBeenCalledBefore(h.build); + await vi.waitFor(() => expect(cindyMakeTestController.hasActiveJobs()).toBe(false)); + }); + it('pushes environment, workspace and child progress through the same completion receipt', async () => { const steps: string[] = []; h.broadcast.mockImplementationOnce(() => {}); @@ -324,10 +383,18 @@ describe('Cindy Make test IPC ownership and persistence', () => { buildId: expect.any(String), startedAt: expect.any(Number), generatedAt: expect.any(Number), + logs: [ + { step: 'environment', at: expect.any(Number) }, + { step: 'original', at: expect.any(Number) }, + { step: 'packaging', at: expect.any(Number) }, + { step: 'ready', at: expect.any(Number) }, + ], }), ); expect(h.rememberOriginal).toHaveBeenCalledWith(process.execPath); expect(h.build.mock.calls[0][0].profile.userData).toBe(h.profile); + expect(h.rollbackGuard?.('published-commit')).toBe(true); + expect(h.publishedCommit).toHaveBeenCalledWith(h.profile, 'published-commit'); await actCindyMakeTest('session', 'completion', 'open-build'); expect(h.artifactPath).toHaveBeenCalledWith(h.profile, expect.any(String), { status: 'ready', @@ -335,6 +402,12 @@ describe('Cindy Make test IPC ownership and persistence', () => { buildId: expect.any(String), startedAt: expect.any(Number), generatedAt: expect.any(Number), + logs: [ + { step: 'environment', at: expect.any(Number) }, + { step: 'original', at: expect.any(Number) }, + { step: 'packaging', at: expect.any(Number) }, + { step: 'ready', at: expect.any(Number) }, + ], }); expect(h.showItem).toHaveBeenCalledWith(path.join(os.tmpdir(), 'installer.exe')); expect(JSON.parse(String(h.card.agentMeta)).otherMetadata).toBe('preserved'); @@ -363,6 +436,7 @@ describe('Cindy Make test IPC ownership and persistence', () => { expect(cindyMakeTestController.isUsingWorkspace(String(h.row.workingDir))).toBe(false), ); expect(h.build).not.toHaveBeenCalled(); + expect(h.recoverBuild).toHaveBeenCalledExactlyOnceWith(true); } finally { release(); await queued; @@ -373,6 +447,51 @@ describe('Cindy Make test IPC ownership and persistence', () => { ); } expect(h.build).not.toHaveBeenCalled(); + expect(h.recoverBuild).toHaveBeenCalledOnce(); + }); + it('finishes automatic withdrawal before releasing a build that fails during environment setup', async () => { + h.probe.mockResolvedValue({ status: 'missing' }); + let release!: () => void; + h.recoverBuild.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + await actCindyMakeTest('session', 'completion', 'build'); + await vi.waitFor(() => expect(h.recoverBuild).toHaveBeenCalledWith(true)); + expect(cindyMakeTestController.isBuilding('session')).toBe(true); + expect(h.build).not.toHaveBeenCalled(); + release(); + await vi.waitFor(() => expect(cindyMakeTestController.isBuilding('session')).toBe(false)); + expect(JSON.parse(String(h.card.agentMeta)).cindyMakeCompletion.personal).toMatchObject({ + status: 'failed', + error: 'environment', + }); + }); + it('rechecks integration after completing an interrupted withdrawal, before generating again', async () => { + h.pendingRollback = true; + h.recoverBuild.mockImplementationOnce(async () => { + h.pendingRollback = false; + h.history.mockResolvedValueOnce({ items: [{ runId: 'run', integration: 'unintegrated' }] }); + }); + await actCindyMakeTest('session', 'completion', 'build'); + await vi.waitFor(() => expect(cindyMakeTestController.isBuilding('session')).toBe(false)); + expect(h.recoverBuild).toHaveBeenCalledExactlyOnceWith(); + expect(h.historyIntegrate).toHaveBeenCalledWith('run', 'integrate'); + expect(h.build).toHaveBeenCalledOnce(); + }); + it('reports incomplete automatic cleanup instead of claiming the source was restored', async () => { + h.probe.mockResolvedValue({ status: 'missing' }); + h.recoverBuild.mockRejectedValueOnce( + Object.assign(new Error('cleanup failed'), { code: 'cleanupFailed' }), + ); + await actCindyMakeTest('session', 'completion', 'build'); + await vi.waitFor(() => expect(cindyMakeTestController.isBuilding('session')).toBe(false)); + expect(JSON.parse(String(h.card.agentMeta)).cindyMakeCompletion.personal).toMatchObject({ + status: 'failed', + error: 'cleanupFailed', + }); }); it('retains active build ownership until cancelled cleanup is done', async () => { let finishCleanup!: () => void; diff --git a/apps/desktop/src/main/cindy-make/__tests__/testTempDirectory.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testTempDirectory.test.ts new file mode 100644 index 0000000000..04dd50b65a --- /dev/null +++ b/apps/desktop/src/main/cindy-make/__tests__/testTempDirectory.test.ts @@ -0,0 +1,67 @@ +import os from 'node:os'; +import path from 'node:path'; +import { mkdtemp, readFile, realpath, rm, rmdir, symlink, writeFile } from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createMakeTestTempDirectory } from '../testTempDirectory'; + +describe('Make test temporary files', () => { + let root: string; + beforeEach(async () => { + root = await mkdtemp(path.join(await realpath(os.tmpdir()), 'make-temp-cleanup-test-')); + }); + afterEach(async () => { + expect(path.dirname(root)).toBe(await realpath(os.tmpdir())); + expect(path.basename(root)).toMatch(/^make-temp-cleanup-test-/); + await rm(root, { recursive: true, force: true }); + }); + + it('removes only this launch, preserving profile data and other launches', async () => { + const first = await createMakeTestTempDirectory(root); + const second = await createMakeTestTempDirectory(root); + const settings = path.join(root, 'test-profile-settings.json'); + await writeFile(settings, 'keep-login-and-settings'); + await writeFile(path.join(first.directory, 'startup.json'), 'temporary-status'); + await writeFile(path.join(second.directory, 'relaunch.json'), 'other-launch'); + await first.clean(); + await first.clean(); + await expect(readFile(path.join(first.directory, 'startup.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }); + expect(await readFile(settings, 'utf8')).toBe('keep-login-and-settings'); + expect(await readFile(path.join(second.directory, 'relaunch.json'), 'utf8')).toBe( + 'other-launch', + ); + await second.clean(); + }); + + it('refuses a replaced cleanup target instead of deleting its new contents', async () => { + const temporary = await createMakeTestTempDirectory(root); + await rmdir(temporary.directory); + await writeFile(temporary.directory, 'not-owned'); + await expect(temporary.clean()).rejects.toThrow('replaced'); + expect(await readFile(temporary.directory, 'utf8')).toBe('not-owned'); + }); + + it('does not follow a replacement junction into a retained profile', async (context) => { + const temporary = await createMakeTestTempDirectory(root); + const retained = await createMakeTestTempDirectory(root); + const settings = path.join(retained.directory, 'settings.json'); + await writeFile(settings, 'retained'); + await rmdir(temporary.directory); + try { + await symlink( + retained.directory, + temporary.directory, + process.platform === 'win32' ? 'junction' : 'dir', + ); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes((error as NodeJS.ErrnoException).code ?? '')) { + context.skip(); + return; + } + throw error; + } + await expect(temporary.clean()).rejects.toThrow('replaced'); + expect(await readFile(settings, 'utf8')).toBe('retained'); + }); +}); diff --git a/apps/desktop/src/main/cindy-make/__tests__/testWindowBehavior.test.ts b/apps/desktop/src/main/cindy-make/__tests__/testWindowBehavior.test.ts new file mode 100644 index 0000000000..1b96211dbe --- /dev/null +++ b/apps/desktop/src/main/cindy-make/__tests__/testWindowBehavior.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createMakeTestWindowBehavior } from '../testWindowBehavior'; + +describe('Cindy Make test window', () => { + const environment = { XDT_CINDY_MAKE_TEST: '1', XDT_ISOLATED: '1' }; + + it('activates the ready preview and quits on close without persisting a tray preference', () => { + const focus = vi.fn(); + const quit = vi.fn(); + const window = createMakeTestWindowBehavior({ isPackaged: false, environment, focus, quit }); + expect(focus).not.toHaveBeenCalled(); + window.ready(); + expect(focus).toHaveBeenCalledOnce(); + const event = { preventDefault: vi.fn() }; + expect(window.close(event)).toBe(true); + expect(event.preventDefault).toHaveBeenCalledBefore(quit); + expect(quit).toHaveBeenCalledOnce(); + }); + + it.each([ + { isPackaged: true, environment }, + { isPackaged: false, environment: {} }, + { isPackaged: false, environment: { XDT_ISOLATED: '1' } }, + { isPackaged: false, environment: { XDT_CINDY_MAKE_TEST: '1' } }, + { isPackaged: false, environment: { ...environment, XDT_CINDY_MAKE_TEST: '0' } }, + ])('preserves normal activation and close behavior for %j', (options) => { + const focus = vi.fn(); + const quit = vi.fn(); + const window = createMakeTestWindowBehavior({ ...options, focus, quit }); + window.ready(); + const event = { preventDefault: vi.fn() }; + expect(window.close(event)).toBe(false); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + expect(quit).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.git-integration.test.ts b/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.git-integration.test.ts index 6afb4b5d12..8b5e9422b5 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.git-integration.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.git-integration.test.ts @@ -6,6 +6,7 @@ import { expect, it } from 'vitest'; import { applyUpstreamMerge, cleanupMergedCandidate, + cancelUpstreamMerge, prepareUpstreamMerge, mergeWorktree, mergeBranch, @@ -129,9 +130,9 @@ it('preserves unfinished or session-owned candidates, new files and commits adde try { const result = await prepareUpstreamMerge(h.userData, h.state, h.git, async () => {}); const worktree = mergeWorktree(h.userData, h.state.id); - expect( - await cleanupMergedCandidate(h.userData, { ...result, status: 'conflict' }, h.git), - ).toBe(false); + expect(await cleanupMergedCandidate(h.userData, { ...result, status: 'conflict' }, h.git)).toBe( + false, + ); expect( await cleanupMergedCandidate(h.userData, { ...result, sessionId: 'active-task' }, h.git), ).toBe(false); @@ -198,6 +199,47 @@ it('moves a personal branch with no custom changes exactly to the official commi } }, 30_000); +it('cancels a conflicting update without merging or losing personal edits and can update again', async () => { + const h = await fixture(true); + try { + const result = await prepareUpstreamMerge(h.userData, h.state, h.git, async () => {}); + expect(result.status).toBe('conflict'); + const worktree = mergeWorktree(h.userData, result.id); + await writeFile(path.join(h.source, 'new-personal.txt'), 'work added while deciding\n'); + await writeFile(path.join(worktree, 'keep.txt'), 'unexpected work\n'); + // Ordinary worktree removal must refuse unexpected files, with no force-delete fallback. + await expect(cancelUpstreamMerge(h.userData, result, h.git)).rejects.toBeTruthy(); + expect(await readFile(path.join(worktree, 'keep.txt'), 'utf8')).toBe('unexpected work\n'); + await rm(path.join(worktree, 'keep.txt')); + await cancelUpstreamMerge(h.userData, result, h.git); + await expect(stat(worktree)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await h.git(['branch', '--list', mergeBranch(result.id)], h.source)).toBe(''); + expect(await h.git(['rev-parse', 'HEAD'], h.source)).toBe(result.baselineCommit); + expect(await readFile(path.join(h.source, 'feature.txt'), 'utf8')).toBe('local feature\n'); + expect(await readFile(path.join(h.source, 'new-personal.txt'), 'utf8')).toBe( + 'work added while deciding\n', + ); + expect(await h.git(['rev-parse', 'main'], h.source)).toBe(result.upstreamCommit); + expect( + await h.git(['rev-parse', 'refs/cindy-make/backups/' + result.id + '/personal'], h.source), + ).toBe(result.baselineCommit); + // A retry after a crash between deletion and saving the cancelled state is harmless. + await cancelUpstreamMerge(h.userData, result, h.git); + const next = await prepareUpstreamMerge( + h.userData, + { ...h.state, id: randomUUID() }, + h.git, + async () => {}, + ); + expect(next.status).toBe('conflict'); + expect(await readFile(path.join(h.source, 'new-personal.txt'), 'utf8')).toBe( + 'work added while deciding\n', + ); + } finally { + await h.clean(); + } +}, 30_000); + it('isolates conflicts, preserves personal files, and applies a resolved merge idempotently', async () => { const h = await fixture(true); try { diff --git a/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.test.ts b/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.test.ts index 4804c10e39..d7a585ec6c 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/upstreamMerge.test.ts @@ -1,6 +1,13 @@ import path from 'node:path'; +import { lstat, realpath } from 'node:fs/promises'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { applyUpstreamMerge, prepareUpstreamMerge, type MergeGit } from '../upstreamMerge'; +import { + applyUpstreamMerge, + cancelUpstreamMerge, + mergeWorktree, + prepareUpstreamMerge, + type MergeGit, +} from '../upstreamMerge'; import { makeSourceCheckoutPath } from '../sourcePaths'; import type { CindyMakeMergeState } from '../../../shared/cindyMakeMerge'; vi.mock('../sourceContent', () => ({ @@ -39,6 +46,8 @@ const state: CindyMakeMergeState = { let git: ReturnType>; const source = makeSourceCheckoutPath(userData); beforeEach(() => { + vi.mocked(realpath).mockImplementation(async (p) => String(p)); + vi.mocked(lstat).mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); git = vi.fn(async (args, cwd) => { const command = args.join(' '); if (command === 'rev-parse --path-format=absolute --git-common-dir') @@ -56,6 +65,52 @@ beforeEach(() => { }); }); describe('upstream merge protection', () => { + it('rejects cancellation of an assigned task or invalid operation before touching Git', async () => { + for (const candidate of [ + { ...state, status: 'conflict' as const, sessionId: 'existing-task' }, + { ...state, status: 'conflict' as const, id: '../source' }, + { ...state, status: 'merged' as const }, + ]) { + await expect(cancelUpstreamMerge(userData, candidate, git)).rejects.toMatchObject({ + code: 'unavailable', + }); + } + expect(git).not.toHaveBeenCalled(); + }); + it('refuses to cancel a candidate whose physical path points elsewhere', async () => { + const worktree = mergeWorktree(userData, state.id); + vi.mocked(lstat).mockResolvedValueOnce({} as Awaited>); + vi.mocked(realpath).mockImplementation(async (p) => + String(p) === worktree ? source : String(p), + ); + await expect( + cancelUpstreamMerge(userData, { ...state, status: 'conflict' }, git), + ).rejects.toMatchObject({ code: 'unavailable' }); + expect( + git.mock.calls.some(([args]) => + ['rebase', 'merge', 'worktree', 'update-ref'].includes(args[0]), + ), + ).toBe(false); + }); + it.each(['other-worktree', 'owner-changed'] as const)( + 'keeps the candidate branch if %s during cancellation', + async (reason) => { + let current = true; + const original = git.getMockImplementation()!; + git.mockImplementation(async (args, cwd) => { + if (args[0] === 'rev-parse' && args[1] === '--verify') return 'c'.repeat(40); + if (args[0] === 'worktree' && args[1] === 'list') { + if (reason === 'owner-changed') current = false; + else return `branch refs/heads/cindy-merge/${state.id}\0`; + } + return original(args, cwd); + }); + await expect( + cancelUpstreamMerge(userData, { ...state, status: 'conflict' }, git, () => current), + ).rejects.toMatchObject({ code: 'busy' }); + expect(git.mock.calls.some(([args]) => args[0] === 'update-ref')).toBe(false); + }, + ); it('rejects invalid operation identities before mutating refs', async () => { await expect( prepareUpstreamMerge(userData, { ...state, id: '../bad' }, git, async () => {}), diff --git a/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeController.test.ts b/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeController.test.ts index eae6563591..f76b473520 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeController.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeController.test.ts @@ -49,6 +49,9 @@ function harness(initial?: SavedUpstreamMerge, actualWorkspace = !!initial?.stat running: vi.fn(() => false), refresh: vi.fn(async () => {}), cleanup: vi.fn(async () => {}), + cancel: vi.fn(async () => { + workspace = false; + }), }; const controller = new UpstreamMergeController(deps); return { @@ -161,23 +164,148 @@ describe('upstream merge lifecycle', () => { state: { status: 'merged', hasWorkspace: false }, }); }); - it('deduplicates simultaneous updates and opens one independent conflict task with the chosen model', async () => { + it('deduplicates updates, waits at a conflict, and starts one task only after explicit confirmation', async () => { const h = harness(); h.deps.prepare = vi.fn(async (state) => ({ ...state, ...candidate })); const options = { agentKind: 'codex' as const, model: 'test-model' }; const results = await Promise.all([h.controller.update(options), h.controller.update(options)]); expect(h.deps.prepare).toHaveBeenCalledOnce(); - expect(results.map((r) => r?.status)).toEqual(['resolving', 'resolving']); - expect(h.deps.session).toHaveBeenCalledOnce(); - expect(vi.mocked(h.deps.session).mock.calls[0][1]).toEqual(options); + expect(results.map((r) => r?.status)).toEqual(['conflict', 'conflict']); + expect(h.deps.session).not.toHaveBeenCalled(); await h.controller.update(); expect(h.deps.prepare).toHaveBeenCalledOnce(); + expect(h.deps.session).not.toHaveBeenCalled(); + await Promise.all([ + h.controller.resolve(options, candidate.id), + h.controller.resolve(options, candidate.id), + ]); expect(h.deps.session).toHaveBeenCalledOnce(); + expect(vi.mocked(h.deps.session).mock.calls[0][1]).toEqual(options); expect(h.saved()).toMatchObject({ sessionOwner: 'alice', state: { sessionId: 'merge-session', status: 'resolving' }, }); }); + it('cancels an unassigned conflict, persists that result, and permits a later update', async () => { + const h = harness({ state: candidate, sessionOwner: 'alice' }); + expect(await h.controller.cancel(candidate.id)).toMatchObject({ + status: 'cancelled', + hasWorkspace: false, + error: undefined, + }); + expect(h.saved()?.state.sessionId).toBeUndefined(); + expect(h.deps.session).not.toHaveBeenCalled(); + expect(h.deps.apply).not.toHaveBeenCalled(); + expect(h.deps.cancel).toHaveBeenCalledOnce(); + const reopened = harness(parseSavedUpstreamMerge(JSON.stringify(h.saved()), '/user-data')); + expect(reopened.controller.status()).toMatchObject({ + status: 'cancelled', + hasWorkspace: false, + }); + await reopened.controller.cancel(candidate.id); + expect(reopened.deps.cancel).not.toHaveBeenCalled(); + await expect(reopened.controller.resolve(undefined, candidate.id)).rejects.toMatchObject({ + code: 'busy', + }); + expect(reopened.saved()?.state.status).toBe('cancelled'); + expect(await reopened.controller.update()).toMatchObject({ status: 'merged' }); + expect(reopened.deps.prepare).toHaveBeenCalledOnce(); + }); + it('retains a failed cancellation for retry, including after the directory has been removed', async () => { + const h = harness({ state: candidate, sessionOwner: 'alice' }); + vi.mocked(h.deps.cancel).mockImplementationOnce(async () => { + expect(h.saved()?.state.cancellationRequested).toBe(true); + h.setWorkspace(false); + throw new Error('ref lock'); + }); + expect(await h.controller.cancel(candidate.id)).toMatchObject({ + status: 'failed', + error: 'cancelFailed', + hasWorkspace: false, + cancellationRequested: true, + }); + const reopened = harness(h.saved()); + expect(await reopened.controller.cancel(candidate.id)).toMatchObject({ + status: 'cancelled', + error: undefined, + hasWorkspace: false, + }); + expect(reopened.deps.cancel).toHaveBeenCalledOnce(); + expect(reopened.deps.session).not.toHaveBeenCalled(); + }); + it.each([true, false])( + 'restores interrupted cancellation for explicit retry (workspace=%s)', + async (workspace) => { + const h = harness( + { state: { ...candidate, cancellationRequested: true }, sessionOwner: 'alice' }, + workspace, + ); + expect(h.saved()?.state).toMatchObject({ + status: 'failed', + error: 'cancelFailed', + hasWorkspace: workspace, + cancellationRequested: true, + }); + expect(h.deps.cancel).not.toHaveBeenCalled(); + await h.controller.update(); + await expect(h.controller.resolve(undefined, candidate.id)).rejects.toMatchObject({ + code: 'busy', + }); + expect(h.deps.prepare).not.toHaveBeenCalled(); + expect(h.deps.session).not.toHaveBeenCalled(); + expect(await h.controller.cancel(candidate.id)).toMatchObject({ + status: 'cancelled', + hasWorkspace: false, + cancellationRequested: undefined, + }); + expect(h.deps.cancel).toHaveBeenCalledOnce(); + }, + ); + it('rejects a stale decision and another account before cancelling or starting a task', async () => { + const h = harness({ state: candidate, sessionOwner: 'alice' }); + await expect(h.controller.cancel('previous-update')).rejects.toMatchObject({ code: 'busy' }); + await expect(h.controller.resolve(undefined, 'previous-update')).rejects.toMatchObject({ + code: 'busy', + }); + h.setOwner('bob'); + await expect(h.controller.cancel(candidate.id)).rejects.toMatchObject({ code: 'busy' }); + await expect(h.controller.resolve(undefined, candidate.id)).rejects.toMatchObject({ + code: 'busy', + }); + expect(h.deps.cancel).not.toHaveBeenCalled(); + expect(h.deps.session).not.toHaveBeenCalled(); + expect(h.saved()?.state.status).toBe('conflict'); + }); + it.each(['resolving', 'checking', 'merged'] as const)( + 'cannot cancel an assigned %s task', + async (status) => { + const h = harness({ + state: { ...candidate, status, sessionId: 'existing-task' }, + sessionOwner: 'alice', + }); + await expect(h.controller.cancel(candidate.id)).rejects.toMatchObject({ code: 'busy' }); + expect(h.deps.cancel).not.toHaveBeenCalled(); + }, + ); + it('never turns a late resolve into a task after cancellation has started', async () => { + const h = harness({ state: candidate, sessionOwner: 'alice' }); + let finish!: () => void; + h.deps.cancel = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const cancelling = h.controller.cancel(candidate.id); + await Promise.resolve(); + await expect(h.controller.resolve(undefined, candidate.id)).rejects.toMatchObject({ + code: 'busy', + }); + finish(); + await cancelling; + expect(h.deps.session).not.toHaveBeenCalled(); + expect(h.saved()?.state.status).toBe('cancelled'); + }); it('retains a conflict without starting a task for an account that changed during the update', async () => { const h = harness(); h.deps.prepare = vi.fn(async (state) => { diff --git a/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeSession.test.ts b/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeSession.test.ts index a61f5b3fde..d8051d6d97 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeSession.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/upstreamMergeSession.test.ts @@ -1,9 +1,10 @@ import path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ensureUpstreamMergeSession, assertUpstreamMergeSession } from '../upstreamMergeSession'; import { mergeWorktree } from '../upstreamMerge'; import type { CindyMakeMergeState } from '../../../shared/cindyMakeMerge'; import { normalizeWorkingDirForStorage } from '../../../shared/workingDir'; +import { setMainLocale } from '../../i18n'; const h = vi.hoisted(() => { const reads: unknown[][] = []; @@ -29,8 +30,8 @@ vi.mock('../../device-link/broadcast-tap.js', () => ({ isDataOwnerBroadcastScopeCurrent: () => h.current, })); vi.mock('../../sessionIds.js', () => ({ createBusinessSessionId: () => 'new-merge-task' })); -vi.mock('../../i18n.js', () => ({ t: (key: string) => key })); const userData = path.resolve('fake-data'); +const createdAt = new Date(2026, 8, 20, 14, 7).getTime(); const state: CindyMakeMergeState = { id: '12345678-1234-1234-1234-123456789abc', status: 'conflict', @@ -47,13 +48,17 @@ const row = { remoteHostId: null, clearedAt: null, agentKind: 'codex', + title: '09-19 09:00 Resolve Source Update Conflicts', }; beforeEach(() => { + setMainLocale('en'); h.reads.length = 0; h.current = true; vi.clearAllMocks(); + vi.spyOn(Date, 'now').mockReturnValue(createdAt); h.insert.mockImplementation(async () => {}); }); +afterEach(() => vi.restoreAllMocks()); describe('dedicated upstream merge session', () => { it('creates the new source, binds before INSERT, preserves preferences, and sends a trusted first request', async () => { const bind = vi.fn(); @@ -67,6 +72,8 @@ describe('dedicated upstream merge session', () => { expect(h.insert).toHaveBeenCalledWith( expect.objectContaining({ source: 'cindy-make-merge', + title: '09-20 14:07 Resolve Source Update Conflicts', + createdAt, workingDir: normalizeWorkingDirForStorage(row.workingDir), model: 'test-model', }), @@ -76,7 +83,89 @@ describe('dedicated upstream merge session', () => { expect(h.dispatch.mock.calls[0][1]).toContain('确保不丢失本地已有功能'); expect(h.dispatch.mock.calls[0][1]).toContain(state.upstreamCommit); }); - it('reopens an existing task without dispatching the first message twice', async () => { + it('distinguishes later conflicts of the same kind using their own creation times', async () => { + await ensureUpstreamMergeSession(userData, state, undefined, vi.fn(), () => true); + const later = new Date(2026, 8, 21, 0, 3).getTime(); + vi.mocked(Date.now).mockReturnValue(later); + await ensureUpstreamMergeSession(userData, state, undefined, vi.fn(), () => true); + expect(h.insert).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + title: '09-20 14:07 Resolve Source Update Conflicts', + createdAt, + }), + ); + expect(h.insert).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + title: '09-21 00:03 Resolve Source Update Conflicts', + createdAt: later, + }), + ); + }); + it.each([ + { + locale: 'en', + titles: [ + 'Resolve Source Update Conflicts', + 'Resolve Integration Conflicts', + 'Resolve Undo Integration Conflicts', + ], + }, + { + locale: 'zh-CN', + titles: ['处理源码更新冲突', '处理合入冲突', '处理撤销合入冲突'], + }, + { + locale: 'zh-TW', + titles: ['處理原始碼更新衝突', '處理合入衝突', '處理撤銷合入衝突'], + }, + { + locale: 'ja', + titles: [ + 'ソース更新時の競合を解決', + '取り込み時の競合を解決', + '取り込み取り消し時の競合を解決', + ], + }, + { + locale: 'ko', + titles: ['소스 업데이트 충돌 해결', '반영 충돌 해결', '반영 취소 충돌 해결'], + }, + ] as const)( + 'creates localized conflict titles with their local creation time in $locale', + async ({ locale, titles }) => { + setMainLocale(locale); + for (const action of [undefined, 'integrate', 'reapply', 'revert'] as const) { + await ensureUpstreamMergeSession( + userData, + { + ...state, + feature: action + ? { + action, + runId: 'feature-run', + taskSessionId: 'feature-task', + taskTree: 'c'.repeat(40), + steps: [], + nextStep: 0, + } + : undefined, + }, + { agentKind: 'codex' }, + vi.fn(), + () => true, + ); + expect(h.insert).toHaveBeenLastCalledWith( + expect.objectContaining({ + title: `09-20 14:07 ${titles[action === undefined ? 0 : action === 'revert' ? 2 : 1]}`, + createdAt, + }), + ); + } + }, + ); + it('reopens an existing task without changing its timestamp or dispatching the first message twice', async () => { h.reads.push([row], [{ id: 'first-message' }]); expect( await ensureUpstreamMergeSession( @@ -89,6 +178,7 @@ describe('dedicated upstream merge session', () => { ).toBe(row.id); expect(h.insert).not.toHaveBeenCalled(); expect(h.dispatch).not.toHaveBeenCalled(); + expect(row.title).toBe('09-19 09:00 Resolve Source Update Conflicts'); }); it('keeps the workspace and creates a new task only after explicitly reopening removed work', async () => { h.reads.push([{ ...row, status: 'deleted' }], []); diff --git a/apps/desktop/src/main/cindy-make/__tests__/versionStartup.test.ts b/apps/desktop/src/main/cindy-make/__tests__/versionStartup.test.ts index c6492b072b..f0c6e8ab79 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/versionStartup.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/versionStartup.test.ts @@ -1,10 +1,11 @@ import { EventEmitter } from 'node:events'; -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import fs from 'node:fs'; -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CINDY_VERSION_PROTOCOL } from '../../../shared/cindyVersions'; const h = vi.hoisted(() => ({ profile: '', appPath: '', @@ -14,7 +15,9 @@ const h = vi.hoisted(() => ({ pty: vi.fn(), exit: vi.fn(), quit: vi.fn(), + relaunch: vi.fn(), errorBox: vi.fn(), + ready: false, live: new Set(), })); vi.mock('electron', () => ({ @@ -33,6 +36,7 @@ vi.mock('electron', () => ({ h.profile = value; }, whenReady: async () => {}, + isReady: () => h.ready, dock: { hide: vi.fn() }, once: vi.fn(), on: vi.fn(), @@ -40,6 +44,7 @@ vi.mock('electron', () => ({ emit: vi.fn(), exit: h.exit, quit: h.quit, + relaunch: h.relaunch, }, dialog: { showErrorBox: h.errorBox }, })); @@ -55,6 +60,7 @@ vi.mock('../../i18n.js', () => ({ t: (key: string) => key })); vi.mock('../../../shared/brandRegion.js', () => ({ CURRENT_CINDY_REGION: 'global' })); const originalExec = process.execPath; +const originalResourcesPath = Object.getOwnPropertyDescriptor(process, 'resourcesPath'); const originalArgv = [...process.argv]; let root = ''; let startup: typeof import('../versionStartup'); @@ -66,8 +72,10 @@ beforeEach(async () => { root = await mkdtemp(path.join(os.tmpdir(), 'cindy-version-startup-')); h.profile = path.join(root, 'profile'); h.appPath = path.join(root, 'checkout', 'apps', 'desktop'); + Object.defineProperty(process, 'resourcesPath', { value: h.appPath, configurable: true }); h.name = 'Cindy'; h.packaged = false; + h.ready = false; await mkdir(h.profile); await mkdir(path.join(h.appPath, 'drizzle'), { recursive: true }); await mkdir(path.join(root, 'checkout/config'), { recursive: true }); @@ -132,6 +140,9 @@ afterEach(async () => { configurable: true, writable: true, }); + if (originalResourcesPath) + Object.defineProperty(process, 'resourcesPath', originalResourcesPath); + else Reflect.deleteProperty(process, 'resourcesPath'); vi.restoreAllMocks(); vi.unstubAllEnvs(); if (root) await rm(root, { recursive: true, force: true }); @@ -139,6 +150,51 @@ afterEach(async () => { function saveOriginal() { store.writeVersionJson(path.join(store.versionsRoot(h.profile), 'original.json'), original); } +/** A retained personal version whose recorded digests match its files unless `corrupt`. */ +async function savePersonalVersion(options: { corrupt?: boolean } = {}) { + const id = randomUUID(); + const directory = store.versionDirectory(h.profile, id); + const resources = path.join(directory, 'resources'); + await mkdir(path.join(resources, 'drizzle'), { recursive: true }); + await writeFile(path.join(directory, 'Cindy.exe'), 'personal executable'); + await writeFile(path.join(resources, 'app.asar'), 'personal application'); + await writeFile( + path.join(resources, 'cindy-version-protocol.json'), + JSON.stringify({ version: CINDY_VERSION_PROTOCOL }), + ); + await writeFile( + path.join(resources, 'drizzle', '0000_base.sql'), + await readFile(path.join(h.appPath, 'drizzle', '0000_base.sql')), + ); + const digest = (value: string) => createHash('sha256').update(value).digest('hex'); + store.writeVersionJson(path.join(directory, 'version.json'), { + protocol: 1, + id, + profile: original.profile, + title: 'Blue background', + commit: 'a'.repeat(40), + builtAt: '2026-09-17T20:00:00.000+08:00', + platform: process.platform, + arch: process.arch, + executable: 'Cindy.exe', + resources: 'resources', + executableHash: digest(options.corrupt ? 'something else' : 'personal executable'), + applicationHash: digest('personal application'), + migrationHash: store.migrationIdentity(path.join(h.appPath, 'drizzle')), + }); + return { id, executable: path.join(directory, 'Cindy.exe') }; +} +/** + * Electron emits 'ready' from the first event-loop turn after the main script; anything the + * dispatcher awaits that is real I/O lets that turn run before bootstrap-electron loads. + */ +function armReadyOnNextTurn() { + const fired = vi.fn(() => { + h.ready = true; + }); + setImmediate(fired); + return fired; +} function launchRequest(patch: Partial = {}) { const value: import('../versionStartup').VersionLaunchRequest = { protocol: 1, @@ -367,3 +423,122 @@ describe('one original version type for Dev and installed Cindy', () => { expect(kill).not.toHaveBeenCalled(); }); }); + +// bootstrap-electron is loaded right after the dispatcher returns false and registers +// privileged schemes plus the 'ready' listener at module top level. Any real I/O awaited on a +// path that keeps this process running lets Electron become ready first (the 2026-09-20 Dev +// startup failure with a recorded original.json). +describe('startup dispatch stays ahead of Electron ready', () => { + it.each([false, true])( + 'rejects an outdated selection using the running original before its registry refresh (packaged=%s)', + async (packaged) => { + h.packaged = packaged; + original.migrationHash = store.migrationIdentity(path.join(h.appPath, 'drizzle')); + saveOriginal(); + const personal = await savePersonalVersion(); + await store.selectVersion(h.profile, personal.id); + await writeFile( + path.join(h.appPath, 'drizzle', '0001_upgrade.sql'), + 'ALTER TABLE sample ADD COLUMN name TEXT;', + ); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(false); + expect(ready).not.toHaveBeenCalled(); + expect(h.spawn).not.toHaveBeenCalled(); + expect(h.exit).not.toHaveBeenCalled(); + expect(store.readOriginalVersion(h.profile)?.migrationHash).toBe(original.migrationHash); + startup.finishCindyVersionStartup(); + await vi.waitFor(() => { + expect(store.selectedVersion(h.profile)).toBe('original'); + expect(store.readOriginalVersion(h.profile)?.migrationHash).toBe( + store.migrationIdentity(path.join(h.appPath, 'drizzle')), + ); + }); + }, + ); + it('opens the recorded original without yielding, then refreshes the record after the lock', async () => { + saveOriginal(); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(false); + expect(ready).not.toHaveBeenCalled(); + expect(store.readOriginalVersion(h.profile)?.version).toBeUndefined(); + startup.finishCindyVersionStartup(); + await vi.waitFor(() => expect(store.readOriginalVersion(h.profile)?.version).toBe('0.1.99')); + expect( + store.readVersionJson(path.join(store.versionsRoot(h.profile), 'active.json')), + ).toMatchObject({ pid: process.pid, id: 'original' }); + expect(h.spawn).not.toHaveBeenCalled(); + }); + it('restores the original with --cindy-version-original and persists the choice after the lock', async () => { + saveOriginal(); + const personal = await savePersonalVersion(); + await store.selectVersion(h.profile, personal.id); + process.argv.push('--cindy-version-original'); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(false); + expect(ready).not.toHaveBeenCalled(); + expect(h.spawn).not.toHaveBeenCalled(); + expect(store.selectedVersion(h.profile)).toBe(personal.id); + startup.finishCindyVersionStartup(); + await vi.waitFor(() => expect(store.selectedVersion(h.profile)).toBe('original')); + }); + it('opens the original in-process when the selected version fails before any real I/O', async () => { + saveOriginal(); + await store.selectVersion(h.profile, randomUUID()); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(false); + expect(ready).not.toHaveBeenCalled(); + expect(h.exit).not.toHaveBeenCalled(); + expect(h.relaunch).not.toHaveBeenCalled(); + startup.finishCindyVersionStartup(); + await vi.waitFor(() => expect(store.selectedVersion(h.profile)).toBe('original')); + }); + it.each([ + ['packaged', true], + ['Dev', false], + ])( + 'resets the selection and leaves when a %s handoff fails after Electron became ready', + async (_label, packaged) => { + saveOriginal(); + const personal = await savePersonalVersion({ corrupt: true }); + await store.selectVersion(h.profile, personal.id); + h.packaged = packaged; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(true); + expect(ready).toHaveBeenCalled(); + expect(store.selectedVersion(h.profile)).toBe('original'); + expect(h.spawn).not.toHaveBeenCalled(); + if (packaged) { + expect(h.relaunch).toHaveBeenCalledWith({ + args: expect.arrayContaining(['--cindy-version-original']), + }); + expect(h.exit).toHaveBeenCalledWith(0); + } else { + expect(h.relaunch).not.toHaveBeenCalled(); + expect(h.exit).toHaveBeenCalledWith(1); + expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Start Dev again')); + } + }, + ); + it('self-verifies a launched personal version synchronously', async () => { + saveOriginal(); + const personal = await savePersonalVersion(); + Object.defineProperty(process, 'execPath', { + value: personal.executable, + configurable: true, + writable: true, + }); + const request = launchRequest({ targetId: personal.id }); + process.argv.push( + '--cindy-version-profile=' + h.profile, + '--cindy-version-launch=' + request.id, + ); + startup.prepareCindyVersionStartup(); + expect(startup.getCurrentCindyVersionId()).toBe(personal.id); + const ready = armReadyOnNextTurn(); + expect(await startup.dispatchCindyVersionStartup()).toBe(false); + expect(ready).not.toHaveBeenCalled(); + expect(h.spawn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/cindy-make/__tests__/versionStore.test.ts b/apps/desktop/src/main/cindy-make/__tests__/versionStore.test.ts index 10f1412e4a..96203f8726 100644 --- a/apps/desktop/src/main/cindy-make/__tests__/versionStore.test.ts +++ b/apps/desktop/src/main/cindy-make/__tests__/versionStore.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { listPersonalVersions, + hasPublishedPersonalVersionCommit, migrationIdentity, publishPersonalVersion, readOriginalVersion, @@ -15,6 +16,7 @@ import { selectedVersion, selectVersion, verifyPersonalVersion, + verifyPersonalVersionSync, versionDirectory, versionsRoot, writeVersionJson, @@ -105,7 +107,9 @@ describe('local Cindy version snapshots', () => { const h = await fixture(); const first = (await h.retain())!; expect(listPersonalVersions(h.userData, h.original)).toEqual([]); + expect(hasPublishedPersonalVersionCommit(h.userData, h.commit)).toBe(false); publishPersonalVersion(h.userData, first); + expect(hasPublishedPersonalVersionCommit(h.userData, h.commit)).toBe(true); const a = await verifyPersonalVersion(h.userData, first, h.original); expect(a.builtAt).toBe(h.builtAt); expect(a.version).toBe(`Cindy Make ${first.slice(0, 8)}`); @@ -149,6 +153,26 @@ describe('local Cindy version snapshots', () => { }); expect(listPersonalVersions(h.userData, h.original)[0].compatible).toBe(false); }); + it('verifies synchronously for the pre-ready startup path with the same outcome', async () => { + const h = await fixture(); + const id = (await h.retain())!; + publishPersonalVersion(h.userData, id); + expect(verifyPersonalVersionSync(h.userData, id, h.original)).toEqual( + await verifyPersonalVersion(h.userData, id, h.original), + ); + const item = readPersonalVersion(h.userData, id); + await writeFile( + path.join(versionDirectory(h.userData, id), item.executable), + 'executable tampered', + ); + let failure: unknown; + try { + verifyPersonalVersionSync(h.userData, id, h.original); + } catch (error) { + failure = error; + } + expect(failure).toMatchObject({ code: 'unavailable' }); + }); it('runs retained personal applications even after the original Dev source directory is removed', async () => { const h = await fixture(); h.original.migrationHash = migrationIdentity(path.join(h.resources, 'drizzle')); diff --git a/apps/desktop/src/main/cindy-make/buildRollback.ts b/apps/desktop/src/main/cindy-make/buildRollback.ts new file mode 100644 index 0000000000..1fd28b1061 --- /dev/null +++ b/apps/desktop/src/main/cindy-make/buildRollback.ts @@ -0,0 +1,114 @@ +import type { CindyMakeHistoryStore, MakeBuildRollbackEntry } from './historyStore.js'; +import { contentRef, snapshotContent, taskContentRef, type ContentGit } from './sourceContent.js'; +import { CINDY_PERSONAL_BRANCH } from './sourcePaths.js'; + +type IsPublishedCommit = (commit: string) => boolean; + +/** Restore only an unchanged, unpublished build candidate; never discard user edits. */ +export async function restoreBuildSource( + git: ContentGit, + source: string, + before: { commit: string; tree: string }, + after: { commit: string; tree: string }, +): Promise { + const head = (await git(['rev-parse', 'HEAD'], source)).trim(); + const tree = await snapshotContent(git, source); + if ( + (await git(['rev-parse', '--abbrev-ref', 'HEAD'], source)).trim() !== CINDY_PERSONAL_BRANCH || + (await git(['status', '--porcelain'], source)).trim() + ) + throw new Error('Personal source changed'); + if (head === before.commit && tree === before.tree) return; + if (head !== after.commit || tree !== after.tree) throw new Error('Personal source changed'); + // Retain the failed candidate independently of the personal branch and task worktree. + await git(['update-ref', 'refs/cindy-make/failed-builds/' + after.commit, after.commit], source); + await git(['reset', '--keep', before.commit], source); + if ( + (await git(['rev-parse', 'HEAD'], source)).trim() !== before.commit || + (await snapshotContent(git, source)) !== before.tree + ) + throw new Error('Personal source recovery incomplete'); +} + +/** Build cleanup owns only the consecutive integrations not present in a saved version. */ +export function historyBuildRollback( + store: CindyMakeHistoryStore, + source: string, + isPublishedCommit: IsPublishedCommit = () => false, +) { + const recover = async (git: ContentGit) => { + const entries = store.readBuildRollback(); + while (entries.length) { + const { runId, receipt, previousTaskTree } = entries[0]; + const record = store.read(runId); + if ( + !record || + record.versions.some((version) => version.operationId === receipt.id) || + (record.receipts.some((entry) => entry.id === receipt.id) && + record.receipts.at(-1)?.id !== receipt.id) + ) + throw new Error('Integration changed during build rollback'); + const ref = taskContentRef(runId, 'integrated'); + const taskTree = await contentRef(git, source, ref); + if ( + taskTree !== previousTaskTree && + taskTree !== (receipt.action === 'revert' ? undefined : receipt.taskTree) + ) + throw new Error('Task integration changed during build rollback'); + await restoreBuildSource( + git, + source, + { commit: receipt.baselineCommit, tree: receipt.beforeTree }, + receipt, + ); + await git( + previousTaskTree ? ['update-ref', ref, previousTaskTree] : ['update-ref', '-d', ref], + source, + ); + store.rollbackReceipt(runId, receipt.id); + entries.shift(); + store.saveBuildRollback(entries); + } + }; + return { + recoverRollback: recover, + prepareRollback: (head: { commit: string; tree: string }, git: ContentGit) => { + const records = store.list(); + const entries: MakeBuildRollbackEntry[] = []; + let current = head; + while (true) { + if ( + isPublishedCommit(current.commit) || + records.some((record) => + record.versions.some((version) => version.commit === current.commit), + ) + ) + break; + const record = records + .sort((a, b) => (b.receipts.at(-1)?.at ?? 0) - (a.receipts.at(-1)?.at ?? 0)) + .find((record) => { + const receipt = record.receipts.at(-1); + return receipt?.commit === current.commit && receipt.tree === current.tree; + }); + if ( + !record || + record.versions.some((version) => version.operationId === record.receipts.at(-1)?.id) + ) + break; + const receipt = record.receipts.pop()!; + const previous = record.receipts.at(-1); + entries.push({ + runId: record.runId, + receipt, + previousTaskTree: previous?.action !== 'revert' ? previous?.taskTree : undefined, + }); + current = { commit: receipt.baselineCommit, tree: receipt.beforeTree }; + } + return async () => { + if (!entries.length) return; + store.saveBuildRollback(entries); + await recover(git); + }; + }, + }; +} diff --git a/apps/desktop/src/main/cindy-make/historyRuntime.ts b/apps/desktop/src/main/cindy-make/historyRuntime.ts index d75d92e927..a6aef72ec4 100644 --- a/apps/desktop/src/main/cindy-make/historyRuntime.ts +++ b/apps/desktop/src/main/cindy-make/historyRuntime.ts @@ -16,10 +16,11 @@ import { cindyMakeManager } from './manager.js'; import { captureMakeHistoryStore } from './historyOwner.js'; import { captureMakeHistoryCompletion, captureMakeHistoryReport } from './historyCapture.js'; import { planFeatureChange } from './featurePlan.js'; -import { taskCommitRef } from './sourceContent.js'; +import { snapshotContent, taskCommitRef } from './sourceContent.js'; import { readLegacyFeatureReceipts } from './historyLegacy.js'; import { integrateMakeHistory, actUpstreamMerge } from './upstreamMergeRuntime.js'; import { manageCindyMakeTask } from './taskManagement.js'; +import { historyBuildRollback } from './buildRollback.js'; import { actCindyMakeTest, cindyMakeTestController } from './testRuntime.js'; import { makeSourceCheckoutPath, @@ -37,10 +38,13 @@ import { buildCindyPersonal, personalArtifactPath, personalBuildEnvironment, + personalBuildError, type PersonalArtifact, } from './personalBuild.js'; import { currentVersionProfile, rememberOriginalVersion } from './versionStartup.js'; +import { hasPublishedPersonalVersionCommit } from './versionStore.js'; import { CURRENT_CINDY_REGION } from '../../shared/brandRegion.js'; +import { isSyntheticTriggerText } from '../../shared/interruptedTurn.js'; import { makeHistoryActions, activeFeatureReceipts, @@ -51,7 +55,10 @@ import { type MakeHistoryIntegration, } from '../../shared/cindyMakeHistory.js'; import type { CindyMakePersonalBuildState } from '../../shared/cindyMakeSession.js'; -import { parseCindyMakeBuildError } from '../../shared/cindyMakeSession.js'; +import { + appendCindyMakeBuildLog, + parseCindyMakeBuildError, +} from '../../shared/cindyMakeSession.js'; const ID = /^[A-Za-z0-9-]{1,128}$/; const HASH = /^[a-f0-9]{40,64}$/i; @@ -66,6 +73,48 @@ let buildJob: done: Promise; } | undefined; +type MakeHistoryCard = Pick< + typeof messages.$inferSelect, + 'id' | 'sessionId' | 'clientId' | 'role' | 'content' | 'agentMeta' | 'createdAt' +>; + +function chronologicalMessageOrder(a: MakeHistoryCard, b: MakeHistoryCard): number { + return a.createdAt - b.createdAt || String(a.id).localeCompare(String(b.id)); +} + +/** Decode the user-authored text stored in a message's JSON content column. */ +function decodeUserPrompt(raw: string): string | undefined { + if (!raw) return; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed === 'string') return parsed.trim() ? parsed : undefined; + if ( + parsed && + typeof parsed === 'object' && + typeof (parsed as { text?: unknown }).text === 'string' + ) { + const text = (parsed as { text: string }).text; + return text.trim() ? text : undefined; + } + } catch { + return raw.trim() ? raw : undefined; + } + return; +} + +function latestUserPromptBefore( + userMessages: MakeHistoryCard[], + completion: MakeHistoryCard, +): string | undefined { + for (let index = userMessages.length - 1; index >= 0; index -= 1) { + const message = userMessages[index]; + if (chronologicalMessageOrder(message, completion) >= 0) continue; + const prompt = decodeUserPrompt(message.content); + if (prompt !== undefined && !isSyntheticTriggerText(prompt)) return prompt; + } + return; +} + export function configureMakeHistory(probe: typeof running): void { running = probe; } @@ -145,12 +194,7 @@ export async function getCindyMakeHistory(selectedRunId?: string): Promise - > = []; + const cards: MakeHistoryCard[] = []; for (let start = 0; start < rows.length; start += 100) { cards.push( ...(await h.client.drizzle @@ -161,7 +205,7 @@ export async function getCindyMakeHistory(selectedRunId?: string): Promise`CASE WHEN ${messages.clientId} LIKE 'cindy-make-preparation-%' THEN ${messages.content} ELSE '' END`, + content: messages.content, }) .from(messages) .where( @@ -207,15 +251,35 @@ export async function getCindyMakeHistory(selectedRunId?: string): Promise [entry.id, entry]), - ); - for (const card of cards.filter((entry) => entry.sessionId === row.id)) { + const record = h.store.read(runId)!; + const knownCompletions = new Map(record.completions.map((entry) => [entry.id, entry])); + const sessionCards = cards + .filter((entry) => entry.sessionId === row.id) + .sort(chronologicalMessageOrder); + const userMessages = sessionCards.filter((entry) => entry.role === 'user'); + const completionCards = sessionCards.filter((entry) => { + try { + const value = JSON.parse(entry.agentMeta ?? '{}').cindyMakeCompletion; + return !!value && Number.isFinite(value.reportedAt); + } catch { + return false; + } + }); + for (const [completionIndex, card] of completionCards.entries()) { try { const completion = JSON.parse(card.agentMeta ?? '{}').cindyMakeCompletion; if (completion && Number.isFinite(completion.reportedAt)) { const known = knownCompletions.get(card.clientId); - const next = { ...completion, id: card.clientId }; + const prompt = latestUserPromptBefore(userMessages, card); + const next = { + ...completion, + id: card.clientId, + ...(prompt !== undefined + ? { prompt } + : completionIndex === 0 && record.request.trim() + ? { prompt: record.request } + : {}), + }; // A legacy tree was verified against this exact commit; an older card cannot erase it. if (!next.tree && next.commit === known?.commit && known?.tree) next.tree = known.tree; if (!next.baseTree && next.commit === known?.commit && known?.baseTree) @@ -235,7 +299,7 @@ export async function getCindyMakeHistory(selectedRunId?: string): Promise entry.runId === runId); const canHide = action === 'hide' && item?.canHide === true; - // Keep a stale cleanup button harmless when this record itself is still - // running, preparing, being tested, or already cleaning. The snapshot - // carries the stable reason so Renderer can show the actionable busy tip - // immediately instead of reporting a generic unavailable action. - if (item?.actionReason === 'busy' && ['end', 'retry-cleanup', 'hide'].includes(action)) - throwIpcError('PRECONDITION_FAILED', 'busy'); - if (!item || (!item.actions.includes(action as MakeHistoryAction) && !canHide)) + if (!item || (!item.actions.includes(action as MakeHistoryAction) && !canHide)) { + // The reason may describe another task blocking source operations while + // this record is still safe to hide. Use it only after this action is denied; + // a stale cleanup button for an occupied task still reports busy. + if (item?.actionReason === 'busy' && ['end', 'retry-cleanup', 'hide'].includes(action)) + throwIpcError('PRECONDITION_FAILED', 'busy'); throwIpcError('PRECONDITION_FAILED', 'unavailable'); - if (action === 'build') return generateHistoryPersonalVersion(); + } + if (['build', 'integrate', 'reapply'].includes(action) && h.store.readBuildRollback().length) { + await recoverHistoryBuildRollback(); + h.check(); + return actCindyMakeHistory(runId, action); + } + if (action === 'build') { + if (item.lifecycle === 'ready' && item.completionId) { + await actCindyMakeTest(item.sessionId, item.completionId, 'build'); + return getCindyMakeHistory(runId); + } + const integration = item.actions.includes('integrate') + ? 'integrate' + : item.actions.includes('reapply') + ? 'reapply' + : undefined; + if (integration) { + const integrated = await actCindyMakeHistory(runId, integration); + if (integrated.items.find((entry) => entry.runId === runId)?.integration !== 'integrated') + return integrated; + } + return generateHistoryPersonalVersion(); + } if (action === 'hide') { // An ended record has already gone through the canonical task cleanup path. // Active/cleanup records must finish that path before they become dismissible. @@ -599,9 +689,16 @@ export async function actCindyMakeHistory( item.completionId, action === 'test' ? 'start' : 'continue', ); - else if (action === 'resolve' || action === 'retry') + else if (action === 'resolve' || action === 'retry') { await actUpstreamMerge({ action: 'resolve' }); - else if (action === 'integrate' || action === 'revert' || action === 'reapply') { + if (action === 'retry') { + h.check(); + const next = await getCindyMakeHistory(runId); + if (next.items.find((entry) => entry.runId === runId)?.actions.includes('build')) + return actCindyMakeHistory(runId, 'build'); + return next; + } + } else if (action === 'integrate' || action === 'revert' || action === 'reapply') { await withSessionRouteLock(item.sessionId, async () => { h.check(); if (running(item.sessionId) || cindyMakeManager.isTaskPreparing(item.sessionId)) @@ -697,7 +794,9 @@ export async function generateHistoryPersonalVersion(): Promise { if (!h.current()) abort.abort(); }, 1000); + let enteredBuilder = false; try { + await publish({ status: 'waiting', preparationStep: 'environment' }); const { tools, env } = await toolEnvironment(h.userData, abort.signal, true); const node = await tools.probe('node', ['--version'], abort.signal); const git = await tools.probe('git', ['--version'], abort.signal); if (!node.path || node.status !== 'ok') throw new Error('environment'); + await publish({ status: 'waiting', preparationStep: 'original' }); await rememberOriginalVersion(node.path); + const buildEnvironment = await personalBuildEnvironment(env, git.path); + enteredBuilder = true; const artifact = await buildCindyPersonal( { mode: 'personal', @@ -728,13 +832,18 @@ export async function generateHistoryPersonalVersion(): Promise cindyMakeManager.withProject(makeSourceRoot(h.userData), run), { + ...historyBuildRollback( + h.store, + makeSourceCheckoutPath(h.userData), + (commit) => hasPublishedPersonalVersionCommit(h.userData, commit), + ), features: () => h.store.list().flatMap((record) => { const last = record.receipts.at(-1); @@ -746,6 +855,13 @@ export async function generateHistoryPersonalVersion(): Promise { + const h = context(); + const source = makeSourceCheckoutPath(h.userData); + try { + if ( + !h.store.readBuildRollback().length && + (!rollbackUnbuilt || + !h.store.list().some((record) => { + const receipt = record.receipts.at(-1); + return receipt && !record.versions.some((version) => version.operationId === receipt.id); + })) + ) + return; + const signal = AbortSignal.timeout(120_000); + const { env } = await toolEnvironment(h.userData, signal); + await cindyMakeManager.withProject(makeSourceRoot(h.userData), async () => { + h.check(); + const git = (args: string[], cwd: string, indexFile?: string) => { + h.check(); + return runSourceGit( + { ...env, ...(indexFile ? { GIT_INDEX_FILE: indexFile } : {}) }, + args, + cwd, + signal, + ); + }; + const rollback = historyBuildRollback(h.store, source, (commit) => + hasPublishedPersonalVersionCommit(h.userData, commit), + ); + await rollback.recoverRollback(git); + if (rollbackUnbuilt) { + const commit = (await git(['rev-parse', 'HEAD'], source)).trim(); + const tree = await snapshotContent(git, source); + await rollback.prepareRollback({ commit, tree }, git)(); + } + }); + } catch { + throw personalBuildError('cleanupFailed'); + } +} export function recordHistoryBuild( store: ReturnType, artifact: PersonalArtifact, diff --git a/apps/desktop/src/main/cindy-make/historyStore.ts b/apps/desktop/src/main/cindy-make/historyStore.ts index b5240b0379..a46b22decf 100644 --- a/apps/desktop/src/main/cindy-make/historyStore.ts +++ b/apps/desktop/src/main/cindy-make/historyStore.ts @@ -8,10 +8,16 @@ import type { MakeHistoryVersion, } from '../../shared/cindyMakeHistory.js'; import type { CindyMakePersonalBuildState } from '../../shared/cindyMakeSession.js'; -import { parseCindyMakeBuildError } from '../../shared/cindyMakeSession.js'; +import { parseCindyMakeBuildError, parseCindyMakeBuildLogs } from '../../shared/cindyMakeSession.js'; const ID = /^[a-zA-Z0-9-]{1,128}$/; const HASH = /^[a-f0-9]{40,64}$/i; +/** Pending source recovery is retained until both Git and history agree again. */ +export interface MakeBuildRollbackEntry { + runId: string; + receipt: MakeFeatureReceipt; + previousTaskTree?: string; +} export function validFeatureReceipt(value: MakeFeatureReceipt): boolean { return ( !!value && @@ -119,9 +125,14 @@ export class CindyMakeHistoryStore { ) ) throw new Error('Invalid build state'); + const logs = parseCindyMakeBuildLogs(value.logs); // The renderer needs status and version identity, never arbitrary fields read from disk. return { status: value.status, + ...(value.status === 'waiting' && + ['environment', 'original'].includes(value.preparationStep) + ? { preparationStep: value.preparationStep } + : {}), ...(value.stopping === true ? { stopping: true } : {}), ...(Number.isFinite(value.startedAt) && value.startedAt > 0 ? { startedAt: value.startedAt } @@ -130,6 +141,7 @@ export class CindyMakeHistoryStore { ['dependencies', 'tests', 'types'].includes(value.checkStep) ? { checkStep: value.checkStep } : {}), + ...(logs ? { logs } : {}), ...(typeof value.buildId === 'string' && ID.test(value.buildId) ? { buildId: value.buildId } : {}), @@ -191,6 +203,7 @@ export class CindyMakeHistoryStore { personal: completion.personal ?? previous.personal, test: completion.test ?? previous.test, lastAction: completion.lastAction ?? previous.lastAction, + prompt: completion.prompt ?? previous.prompt, }; if (index < 0) record.completions.push(completion); else if (JSON.stringify(record.completions[index]) === JSON.stringify(completion)) return; @@ -207,6 +220,49 @@ export class CindyMakeHistoryStore { record.updatedAt = Math.max(record.updatedAt, receipt.at); this.save(record); } + readBuildRollback(): MakeBuildRollbackEntry[] { + this.assertDirectories(); + const file = path.join(this.directory, 'build-rollback.json'); + for (const candidate of [file, file + '.bak']) { + try { + const info = fs.lstatSync(candidate); + if (!info.isFile() || info.isSymbolicLink()) throw new Error('Invalid build rollback file'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } + const raw = readAtomicFileSync(file); + if (raw === null) return []; + const entries = JSON.parse(raw) as MakeBuildRollbackEntry[]; + if ( + !Array.isArray(entries) || + !entries.every( + (entry) => + !!entry && + typeof entry.runId === 'string' && + ID.test(entry.runId) && + validFeatureReceipt(entry.receipt) && + (entry.previousTaskTree === undefined || HASH.test(entry.previousTaskTree)), + ) + ) + throw new Error('Invalid build rollback'); + return entries; + } + saveBuildRollback(entries: MakeBuildRollbackEntry[]): void { + this.assertDirectories(); + atomicWriteFileSync(path.join(this.directory, 'build-rollback.json'), JSON.stringify(entries)); + } + rollbackReceipt(runId: string, receiptId: string): void { + const record = this.read(runId); + if (!record || !record.receipts.some((entry) => entry.id === receiptId)) return; + if ( + record.receipts.at(-1)?.id !== receiptId || + record.versions.some((version) => version.operationId === receiptId) + ) + throw new Error('Integration changed during build rollback'); + record.receipts.pop(); + this.save(record); + } verifyCompletionFacts( runId: string, id: string, diff --git a/apps/desktop/src/main/cindy-make/manager.ts b/apps/desktop/src/main/cindy-make/manager.ts index a976652bb8..c4270eb920 100644 --- a/apps/desktop/src/main/cindy-make/manager.ts +++ b/apps/desktop/src/main/cindy-make/manager.ts @@ -126,7 +126,9 @@ export class CindyMakeManager { this.isProjectBusy(root) || (!!this.states.upstreamMerge && this.states.upstreamMerge.status !== 'merged' && + this.states.upstreamMerge.status !== 'cancelled' && (this.states.upstreamMerge.hasWorkspace === true || + this.states.upstreamMerge.cancellationRequested === true || this.states.upstreamMerge.status !== 'failed')) ); } @@ -261,7 +263,10 @@ export class CindyMakeManager { ); } if (input.signal.aborted) return input.cancelled(); - if (this.states.upstreamMerge?.hasWorkspace && this.states.upstreamMerge.status !== 'merged') + if ( + this.states.upstreamMerge?.cancellationRequested || + (this.states.upstreamMerge?.hasWorkspace && this.states.upstreamMerge.status !== 'merged') + ) throw Object.assign(new Error('upstream merge is pending'), { code: 'busy' }); if ( input.clearOnly && diff --git a/apps/desktop/src/main/cindy-make/personalBuild.ts b/apps/desktop/src/main/cindy-make/personalBuild.ts index 9035fc11c3..5c238ca320 100644 --- a/apps/desktop/src/main/cindy-make/personalBuild.ts +++ b/apps/desktop/src/main/cindy-make/personalBuild.ts @@ -13,7 +13,14 @@ import { type VersionProfile, } from './versionStore.js'; import { defaultPtySpawn, type PtySpawnFn } from '../terminal/ptyFactory.js'; -import { snapshotContent, contentRef, applyContent, taskContentRef } from './sourceContent.js'; +import { + snapshotContent, + contentRef, + applyContent, + taskContentRef, + type ContentGit, +} from './sourceContent.js'; +import { restoreBuildSource } from './buildRollback.js'; import { runSourceGit } from './sourceGit.js'; import { runSourcePnpm } from './sourcePnpm.js'; import { makeSourceCheckoutPath, makeSourceRoot, CINDY_PERSONAL_BRANCH } from './sourcePaths.js'; @@ -85,15 +92,22 @@ export function runPersonalPackageCommand( spawn: PtySpawnFn = defaultPtySpawn, ): Promise { signal.throwIfAborted(); + // Only the builder's resolved commit may cross the otherwise clean child environment. + const migrationBase = env.XDT_MIGRATION_BASE_REF; + if (migrationBase !== undefined && !/^[0-9a-f]{40,64}$/i.test(migrationBase)) + throw personalBuildError('changed'); return new Promise((resolve, reject) => { const child = spawn(node, args, { cwd, - env: makeTestEnvironment(env), + env: { + ...makeTestEnvironment(env), + ...(migrationBase ? { XDT_MIGRATION_BASE_REF: migrationBase } : {}), + }, cols: 4096, rows: 30, name: 'xterm-256color', }); - // Drain output so compiler progress cannot block the process; never publish raw build logs. + // Drain output so compiler progress cannot block the process; raw logs never cross the build boundary. child.onData(() => {}); const abort = () => { try { @@ -157,6 +171,11 @@ interface BuildDeps { packageCommand?: typeof runPersonalPackageCommand; verify?: typeof verifyMakeTestWorkspace; features?: () => Array<{ runId: string; operationId: string }>; + recoverRollback?: (git: ContentGit) => Promise; + prepareRollback?: ( + head: { commit: string; tree: string }, + git: ContentGit, + ) => () => Promise; } /** Integrate task files first, then build only in the locked cindy-personal checkout. */ @@ -201,14 +220,28 @@ export async function buildCindyPersonal( signal, ); }; - const pnpm = (args: string[]) => { - check(); - return (deps.pnpm ?? runSourcePnpm)(environment, args, source, signal); - }; let baseline = ''; let baselineTree = ''; let candidateTree = ''; let taskTree = ''; + let original: { commit: string; tree: string } | undefined; + let previousTaskTree: string | undefined; + let adopted = false; + let rollbackHistory: (() => Promise) | undefined; + const report = (next: CindyMakePersonalBuildState) => publish(next); + const pnpm = (args: string[]) => { + check(); + return (deps.pnpm ?? runSourcePnpm)(environment, args, source, signal); + }; + const recoveryGit: ContentGit = (args, cwd, indexFile) => { + checkCurrent(); + return (deps.git ?? runSourceGit)( + { ...environment, ...(indexFile ? { GIT_INDEX_FILE: indexFile } : {}) }, + args, + cwd, + AbortSignal.timeout(120_000), + ); + }; const assertSource = async (tree: string) => { if ( (await git(['rev-parse', 'HEAD'])).trim() !== baseline || @@ -219,7 +252,7 @@ export async function buildCindyPersonal( }; try { check(); - await publish({ status: 'merging' }); + await report({ status: 'merging' }); await verifyTask(); const canonicalSource = await realpath(source); if ((await git(['rev-parse', '--abbrev-ref', 'HEAD'])).trim() !== CINDY_PERSONAL_BRANCH) @@ -228,14 +261,22 @@ export async function buildCindyPersonal( (deps.git ?? runSourceGit)(environment, args, cwd, AbortSignal.timeout(30_000)), ); await cleanup.clean(); + try { + await deps.recoverRollback?.(recoveryGit); + } catch { + throw personalBuildError('cleanupFailed'); + } const personal = await commitPersonalFiles(git, source); baseline = personal.commit; baselineTree = personal.tree; + original = personal; + rollbackHistory = deps.prepareRollback?.(personal, recoveryGit); if (editingTask) { const task = editingTask; + previousTaskTree = await contentRef(git, source, taskContentRef(task.runId, 'integrated')); taskTree = task.tree ?? (await git(['rev-parse', task.commit + '^{tree}'])).trim(); const taskBase = - (await contentRef(git, source, taskContentRef(task.runId, 'integrated'))) ?? + previousTaskTree ?? (await contentRef(git, source, taskContentRef(task.runId, 'base'))) ?? ( await git([ @@ -273,7 +314,7 @@ export async function buildCindyPersonal( await assertSource(baselineTree); check(); // Adopt the local merge commit before checking/packaging in the real personal checkout. - // Cancellation never discards the already integrated commit or the editing worktree. + // The candidate remains provisional until a verified version is published. const adoptGit = (args: string[], cwd: string, indexFile?: string) => (deps.git ?? runSourceGit)( { ...environment, ...(indexFile ? { GIT_INDEX_FILE: indexFile } : {}) }, @@ -282,19 +323,34 @@ export async function buildCindyPersonal( AbortSignal.timeout(120_000), ); await adoptGit(['merge', '--ff-only', candidateCommit], source); - await adoptGit(['update-ref', taskContentRef(task.runId, 'integrated'), taskTree], source); baseline = candidateCommit; + adopted = true; + await adoptGit(['update-ref', taskContentRef(task.runId, 'integrated'), taskTree], source); } else { candidateTree = baselineTree; } const includedFeatures = deps.features?.(); + // Make's local main follows the selected release, while origin/main may already + // contain later migrations. Freeze the official history actually inherited by + // this personal version; never bless personal edits by using its own HEAD. + let officialRef = 'refs/heads/main'; + try { + await git(['show-ref', '--verify', '--quiet', officialRef]); + } catch (error) { + if ((error as { exitCode?: number }).exitCode !== 1) throw error; + // Cloning a release tag leaves HEAD detached and creates no local main. + // Use its shared history with origin/main, never the newer remote tip itself. + officialRef = 'refs/remotes/origin/main'; + } + const migrationBase = (await git(['merge-base', baseline, officialRef])).trim(); + if (!/^[0-9a-f]{40,64}$/i.test(migrationBase)) throw personalBuildError('changed'); check(); try { - await publish({ status: 'checking', checkStep: 'dependencies' }); + await report({ status: 'checking', checkStep: 'dependencies' }); await pnpm(['install', '--frozen-lockfile', '--prefer-offline', '--prod=false']); - await publish({ status: 'checking', checkStep: 'tests' }); + await report({ status: 'checking', checkStep: 'tests' }); await pnpm(['test:unit:related']); - await publish({ status: 'checking', checkStep: 'types' }); + await report({ status: 'checking', checkStep: 'types' }); await pnpm(['--recursive', '--workspace-concurrency=1', '--if-present', 'typecheck']); } catch { throw personalBuildError(signal.aborted ? 'interrupted' : 'checksFailed'); @@ -324,7 +380,7 @@ export async function buildCindyPersonal( } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - await publish({ status: 'packaging' }); + await report({ status: 'packaging' }); check(); await cleanup.captureManifest(); await (deps.packageCommand ?? runPersonalPackageCommand)( @@ -340,7 +396,7 @@ export async function buildCindyPersonal( '--no-sign', ], source, - environment, + { ...environment, XDT_MIGRATION_BASE_REF: migrationBase }, signal, ); check(); @@ -414,7 +470,7 @@ export async function buildCindyPersonal( }); if (versionId) artifact.versionId = versionId; } - await publish({ status: 'publishing' }); + await report({ status: 'publishing' }); await verifyTask(); await cleanup.restoreManifest(); await cleanup.clean(); @@ -494,6 +550,32 @@ export async function buildCindyPersonal( cleanupFailed = true; } } + if (!published) { + try { + if (adopted && original && editingTask) { + await restoreBuildSource(recoveryGit, source, original, { + commit: baseline, + tree: candidateTree, + }); + const ref = taskContentRef(editingTask.runId, 'integrated'); + await recoveryGit( + previousTaskTree ? ['update-ref', ref, previousTaskTree] : ['update-ref', '-d', ref], + source, + ); + } + // Cancellation or a setup failure can happen before the baseline was read. + // Already integrated history still belongs to this failed generation. + if (!rollbackHistory && deps.prepareRollback) { + await deps.recoverRollback?.(recoveryGit); + const commit = (await recoveryGit(['rev-parse', 'HEAD'], source)).trim(); + const tree = await snapshotContent(recoveryGit, source); + rollbackHistory = deps.prepareRollback({ commit, tree }, recoveryGit); + } + await rollbackHistory?.(); + } catch { + cleanupFailed = true; + } + } if (cleanupFailed && !published) throw personalBuildError('cleanupFailed'); } }); diff --git a/apps/desktop/src/main/cindy-make/remoteProjection.ts b/apps/desktop/src/main/cindy-make/remoteProjection.ts index 28fe962fb2..7725fb97e8 100644 --- a/apps/desktop/src/main/cindy-make/remoteProjection.ts +++ b/apps/desktop/src/main/cindy-make/remoteProjection.ts @@ -114,6 +114,8 @@ export function projectMakeRemoteCard(source: MakeRemoteSnapshot, t: Translate): buildMode && personal ? personal.stopping ? 'cindyMake.history.stopping' + : personal.status === 'waiting' && personal.preparationStep + ? 'cindyMake.personal.preparationStep.' + personal.preparationStep : personal.status === 'checking' && personal.checkStep ? 'cindyMake.personal.checkStep.' + personal.checkStep : 'cindyMake.personal.status.' + personal.status @@ -140,14 +142,21 @@ export function projectMakeRemoteCard(source: MakeRemoteSnapshot, t: Translate): details.push( t((buildMode ? 'cindyMake.personal.errors.' : 'cindyMake.test.errors.') + error), ); + if (buildMode && personal?.logs?.length) + details.push( + t('cindyMake.personal.buildLog.title') + + ': ' + + personal.logs + .slice(-8) + .map((entry) => t('cindyMake.personal.buildLog.steps.' + entry.step)) + .join(' · '), + ); action(`test:${id}:continue`, 'cindyMake.test.continue', starting || building); action( `test:${id}:start`, testStatus === 'ready' ? 'cindyMake.test.started' - : ['failed', 'stopped'].includes(testStatus) - ? 'cindyMake.test.retry' - : 'cindyMake.test.start', + : 'cindyMake.test.start', starting || building || testStatus === 'ready' || !meta.commit, ); action(`test:${id}:build`, 'cindyMake.personal.generate', starting || building || !meta.commit); diff --git a/apps/desktop/src/main/cindy-make/sourcePreparation.ts b/apps/desktop/src/main/cindy-make/sourcePreparation.ts index 3a493e6567..3ebe859be7 100644 --- a/apps/desktop/src/main/cindy-make/sourcePreparation.ts +++ b/apps/desktop/src/main/cindy-make/sourcePreparation.ts @@ -112,9 +112,12 @@ export async function readCurrentCindySourceStatus( const sourcePath = path.resolve(root, 'source'); if (env && (await exists(path.join(sourcePath, '.git')))) { const signal = new AbortController().signal; - const personalCommit = await git(env, ['rev-parse', 'HEAD'], sourcePath, signal).catch( - () => '', - ); + const personalCommit = await git( + env, + ['rev-parse', '--verify', `refs/heads/${CINDY_PERSONAL_BRANCH}^{commit}`], + sourcePath, + signal, + ).catch(() => ''); const upstreamRef = status.ref ? status.ref === 'main' ? 'refs/remotes/origin/main^{commit}' @@ -532,7 +535,8 @@ async function prepareCindySourceInternal( const dirty = await git(env, ['status', '--porcelain'], sourcePath, signal); if ( dirty && - (await git(env, ['branch', '--show-current'], sourcePath, signal)).trim() !== CINDY_PERSONAL_BRANCH + (await git(env, ['branch', '--show-current'], sourcePath, signal)).trim() !== + CINDY_PERSONAL_BRANCH ) throw Object.assign(new Error('dirty'), { code: 'dirty' }); } diff --git a/apps/desktop/src/main/cindy-make/testController.ts b/apps/desktop/src/main/cindy-make/testController.ts index 554374e086..ad5d8437ce 100644 --- a/apps/desktop/src/main/cindy-make/testController.ts +++ b/apps/desktop/src/main/cindy-make/testController.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { appendCindyMakeBuildLog } from '../../shared/cindyMakeSession.js'; import type { CindyMakeCompletionMeta, CindyMakeTestAction, @@ -63,13 +64,33 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { job.controller.abort(); job.process?.stop(); }; + const waitForStopped = async (job: TestJob) => { + if (job.kind !== 'test') return job.finished; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + job.finished, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(makeTestError('stopFailed')), 10_000); + }), + ]); + } finally { + clearTimeout(timeout); + } + // A timeout only rejects the action; execute retains the workspace lease and + // temporary files until the real process exit. A later action may retry stopping. + }; const saveBuild = (job: TestJob, state: CindyMakePersonalBuildState) => { const pending = job.persistence.then(async () => { if (!job.context.isCurrent()) throw makeTestError('unavailable'); if (state.stopping && ['ready', 'failed'].includes(job.context.meta.personal?.status ?? '')) return; if (job.cancelled && !state.stopping && !['ready', 'failed'].includes(state.status)) return; - const next = { ...state, buildId: job.buildId, startedAt: job.startedAt }; + const next = appendCindyMakeBuildLog(job.context.meta.personal, { + ...state, + buildId: job.buildId, + startedAt: job.startedAt, + }); job.context.meta = await deps.save(job.context, { lastAction: 'build', personal: next }); deps.onBuildState?.(job.context, next); }); @@ -201,7 +222,7 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { if (!job.context.isCurrent()) throw makeTestError('unavailable'); stop(job); await job.accepted.catch(() => {}); - await job.finished; + await waitForStopped(job); }, isUsingWorkspace(workingDir: string): boolean { return [...jobs.values()].some((job) => job.context.workingDir === workingDir); @@ -209,6 +230,16 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { stopAll(): void { for (const job of jobs.values()) stop(job); }, + async stopAllAndWait(): Promise { + const active = [...jobs.values()]; + for (const job of active) stop(job); + await Promise.all( + active.map(async (job) => { + await job.accepted.catch(() => {}); + await waitForStopped(job); + }), + ); + }, async act( sessionId: string, completionId: string, @@ -224,7 +255,7 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { throw makeTestError('unavailable'); if (previous && previous.context.isCurrent()) { stop(previous); - if (previous.kind === 'test') await previous.finished; + if (previous.kind === 'test') await waitForStopped(previous); } return deps.save(context, { continuedAt: (deps.now ?? Date.now)() }); } @@ -238,12 +269,12 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { context.meta.personal?.status ?? '', ) ) - patch.personal = { + patch.personal = appendCindyMakeBuildLog(context.meta.personal, { ...context.meta.personal, status: 'failed', stopping: undefined, error: 'interrupted', - }; + }); if (Object.keys(patch).length) return deps.save(context, patch); return context.meta; } @@ -274,7 +305,7 @@ export function createMakeTestController(deps: MakeTestControllerDeps) { return previous.accepted.then(() => previous!.context.meta); stop(previous); await previous.accepted.catch(() => {}); - await previous.finished; + await waitForStopped(previous); context = await deps.load(sessionId, completionId); if (!context.isCurrent() || context.meta.continuedAt) throw makeTestError('unavailable'); previous = jobs.get(sessionId); diff --git a/apps/desktop/src/main/cindy-make/testProcess.ts b/apps/desktop/src/main/cindy-make/testProcess.ts new file mode 100644 index 0000000000..3cb8a7bb3c --- /dev/null +++ b/apps/desktop/src/main/cindy-make/testProcess.ts @@ -0,0 +1,31 @@ +import type { IPty } from 'node-pty'; + +/** Stop the owned PTY tree, including Forge/Vite descendants on macOS and Linux. */ +export function stopMakeTestProcess( + child: Pick, + platform: NodeJS.Platform = process.platform, + kill: typeof process.kill = process.kill, +): void { + if (platform !== 'win32' && Number.isSafeInteger(child.pid) && child.pid > 0) { + // forkpty creates a private session/process group. Signalling only its Node + // entry point can orphan the compiler and Electron processes beneath it. + try { + kill(-child.pid, 'SIGKILL'); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + // Group already gone; close the PTY process itself. + } else { + // EPERM (and similar) on the group must not skip the leaf process. + try { + child.kill(); + return; + } catch { + throw error; + } + } + } + } + // node-pty owns the Windows console tree and closes it via ConPTY. + child.kill(); +} diff --git a/apps/desktop/src/main/cindy-make/testRunner.ts b/apps/desktop/src/main/cindy-make/testRunner.ts index 89eeab614d..4bf330c91b 100644 --- a/apps/desktop/src/main/cindy-make/testRunner.ts +++ b/apps/desktop/src/main/cindy-make/testRunner.ts @@ -4,6 +4,8 @@ import { lstat, realpath } from 'node:fs/promises'; import { defaultPtySpawn, type PtySpawnFn } from '../terminal/ptyFactory.js'; import { snapshotContent } from './sourceContent.js'; import { runSourceGit } from './sourceGit.js'; +import { createMakeTestTempDirectory } from './testTempDirectory.js'; +import { stopMakeTestProcess } from './testProcess.js'; import { CINDY_MAKE_RUN_ID_PATTERN, makeSourceCheckoutPath, @@ -199,14 +201,15 @@ const WRAPPER_FAILURE_CODES = new Set([ export interface MakeTestDiagnostic { event: 'spawn' | 'step' | 'ready' | 'failed' | 'exit'; step?: CindyMakeTestStep; - reason?: 'wrapperFailed' | 'earlyExit' | 'identityMismatch' | 'timeout'; + reason?: + 'wrapperFailed' | 'earlyExit' | 'identityMismatch' | 'timeout' | 'cleanupFailed' | 'stopFailed'; wrapperCode?: string; exitCode?: number; elapsedMs: number; } /** The existing restart pipeline keeps its TTY runner without opening a terminal window. */ -export function launchMakeTest( +export async function launchMakeTest( task: MakeTestWorkspace, tools: { node: string; pnpm: string }, environment: NodeJS.ProcessEnv, @@ -215,7 +218,7 @@ export function launchMakeTest( spawn: PtySpawnFn = defaultPtySpawn, onStep?: (step: CindyMakeTestStep) => void, onDiagnostic?: (diagnostic: MakeTestDiagnostic) => void, -): MakeTestProcess { +): Promise { signal.throwIfAborted(); const startedAt = Date.now(); const diagnostic = (value: Omit) => @@ -226,30 +229,52 @@ export function launchMakeTest( .update(task.userData + ':' + task.runId) .digest('hex') .slice(0, 20); - const child = spawn( - tools.node, - [ - path.join(task.workingDir, 'scripts', 'desktop-restart-runner.mjs'), - '--wait-ready', - '--region=' + region, - '--isolated=' + sandbox, - '--passive', - ], - { - cwd: task.workingDir, - env: { - ...makeTestEnvironment(environment), - // Use the probed entry, not the host's npm_execpath. On Windows, the - // restart runner's bare pnpm fallback can give .cmd shims the task cwd - // as %~dp0, so they look for pnpm.cjs outside their installation. - npm_execpath: tools.pnpm, + const temporary = await createMakeTestTempDirectory(); + const clean = async () => { + try { + await temporary.clean(); + } catch { + diagnostic({ event: 'failed', reason: 'cleanupFailed' }); + } + }; + let child: ReturnType; + try { + signal.throwIfAborted(); + child = spawn( + tools.node, + [ + path.join(task.workingDir, 'scripts', 'desktop-restart-runner.mjs'), + '--wait-ready', + '--region=' + region, + '--isolated=' + sandbox, + '--passive', + ], + { + cwd: task.workingDir, + env: { + ...makeTestEnvironment(environment), + // Use the probed entry, not the host's npm_execpath. On Windows, the + // restart runner's bare pnpm fallback can give .cmd shims the task cwd + // as %~dp0, so they look for pnpm.cjs outside their installation. + npm_execpath: tools.pnpm, + // The child activates its ready window and exits when that window closes. + XDT_CINDY_MAKE_TEST: '1', + // Existing launchers already use os.tmpdir(). Keep their startup and + // relaunch markers (including those from older task sources) together. + TMPDIR: temporary.directory, + TMP: temporary.directory, + TEMP: temporary.directory, + }, + // Keep the machine-readable identity lines intact even with long profile paths. + cols: 4096, + rows: 30, + name: 'xterm-256color', }, - // Keep the machine-readable identity lines intact even with long profile paths. - cols: 4096, - rows: 30, - name: 'xterm-256color', - }, - ); + ); + } catch (error) { + await clean(); + throw error; + } let resolveReady!: () => void; let rejectReady!: (error: Error) => void; let resolveClosed!: () => void; @@ -279,7 +304,9 @@ export function launchMakeTest( settled = true; rejectReady(makeTestError('interrupted')); } - resolveClosed(); + // The controller retains its workspace lease until temporary cleanup settles, + // so Continue Editing and Generate Personal Version cannot overtake it. + void clean().then(resolveClosed); }; const stop = () => { if (stopped || stopping) return; @@ -291,9 +318,11 @@ export function launchMakeTest( rejectReady(makeTestError('interrupted')); } try { - child.kill(); + stopMakeTestProcess(child); } catch { - finish(); + // A failed stop is not an exit receipt. Do not delete files still in use. + diagnostic({ event: 'failed', reason: 'stopFailed' }); + stopping = false; } }; const fail = ( diff --git a/apps/desktop/src/main/cindy-make/testRuntime.ts b/apps/desktop/src/main/cindy-make/testRuntime.ts index 23000119c6..d93826455d 100644 --- a/apps/desktop/src/main/cindy-make/testRuntime.ts +++ b/apps/desktop/src/main/cindy-make/testRuntime.ts @@ -16,6 +16,7 @@ import type { CindyMakeCompletionMeta, CindyMakeTestAction, } from '../../shared/cindyMakeSession.js'; +import { parseCindyMakeBuildLogs } from '../../shared/cindyMakeSession.js'; import { createMakeTestController, type MakeTestContext } from './testController.js'; import { launchMakeTest, makeTestError, verifyMakeTestWorkspace } from './testRunner.js'; import { @@ -23,7 +24,8 @@ import { resolveMakeToolEnvironment, } from './toolchainEnvironment.js'; import { cindyMakeManager } from './manager.js'; -import { isCindyMakeWorktreePath, makeSourceRoot } from './sourcePaths.js'; +import { isCindyMakeWorktreePath, makeSourceRoot, makeSourceCheckoutPath } from './sourcePaths.js'; +import { historyBuildRollback } from './buildRollback.js'; import { buildCindyPersonal, personalArtifactPath, @@ -32,6 +34,7 @@ import { } from './personalBuild.js'; import { untilAborted } from './doctor.js'; import { currentVersionProfile, rememberOriginalVersion } from './versionStartup.js'; +import { hasPublishedPersonalVersionCommit } from './versionStore.js'; import { captureMakeHistoryStore } from './historyOwner.js'; import { captureMakeHistoryCompletion } from './historyCapture.js'; import { broadcastMakeRemoteChanged } from './remoteBroadcast.js'; @@ -50,8 +53,16 @@ interface StoredContext extends MakeTestContext { function readCompletion(agentMeta: string | null): CindyMakeCompletionMeta { try { const meta = JSON.parse(agentMeta ?? '{}').cindyMakeCompletion; - if (meta && typeof meta.reportedAt === 'number' && Number.isFinite(meta.reportedAt)) + if (meta && typeof meta.reportedAt === 'number' && Number.isFinite(meta.reportedAt)) { + if (meta.personal) { + const logs = parseCindyMakeBuildLogs(meta.personal.logs); + return { + ...meta, + personal: { ...meta.personal, ...(logs ? { logs } : {}) }, + }; + } return meta; + } } catch {} throw makeTestError('unavailable'); } @@ -165,6 +176,18 @@ async function save( if (patch.test.status === 'failed') log.warn('Isolated test failed', receipt); else log.debug('Isolated test state', receipt); } + if (patch.personal) { + const receipt = { + runId: context.runId, + completionId: context.completionId, + buildId: patch.personal.buildId, + status: patch.personal.status, + checkStep: patch.personal.checkStep, + error: patch.personal.error, + }; + if (patch.personal.status === 'failed') log.warn('Personal build failed', receipt); + else log.debug('Personal build state', receipt); + } captureMakeHistoryCompletion( captureMakeHistoryStore(), context.runId, @@ -198,6 +221,7 @@ export const cindyMakeTestController = createMakeTestController({ withUse: (context, run) => cindyMakeManager.withProjectUse(makeSourceRoot(context.userData), run), build: (context, signal, publish) => { let entered = false; + let enteredBuilder = false; const waiting = new AbortController(); const abortWaiting = () => { if (!entered) waiting.abort(); @@ -209,6 +233,7 @@ export const cindyMakeTestController = createMakeTestController({ async () => { entered = true; signal.throwIfAborted(); + await publish({ status: 'waiting', preparationStep: 'environment' }); const fresh = await load(context.sessionId, context.completionId); if ( !context.isCurrent() || @@ -231,9 +256,11 @@ export const cindyMakeTestController = createMakeTestController({ if (node.status !== 'ok' || !node.path || !path.isAbsolute(node.path)) throw makeTestError('environment'); const buildEnv = await personalBuildEnvironment(env, git.path); + await publish({ status: 'waiting', preparationStep: 'original' }); await rememberOriginalVersion(node.path); const historyStore = captureMakeHistoryStore(); - return buildCindyPersonal( + enteredBuilder = true; + return await buildCindyPersonal( { mode: 'personal', userData: context.userData, @@ -251,6 +278,11 @@ export const cindyMakeTestController = createMakeTestController({ }, (run) => cindyMakeManager.withProject(makeSourceRoot(context.userData), run), { + ...historyBuildRollback( + historyStore, + makeSourceCheckoutPath(context.userData), + (commit) => hasPublishedPersonalVersionCommit(context.userData, commit), + ), features: () => historyStore.list().flatMap((record) => { const last = record.receipts.at(-1); @@ -261,9 +293,15 @@ export const cindyMakeTestController = createMakeTestController({ }, signal, ); - return untilAborted(work, waiting.signal).finally(() => - signal.removeEventListener('abort', abortWaiting), - ); + return untilAborted(work, waiting.signal) + .catch(async (error) => { + if (!enteredBuilder && context.isCurrent()) { + const { recoverHistoryBuildRollback } = await import('./historyRuntime.js'); + if (context.isCurrent()) await recoverHistoryBuildRollback(true); + } + throw error; + }) + .finally(() => signal.removeEventListener('abort', abortWaiting)); }, openBuild: async (context) => { try { @@ -360,16 +398,26 @@ export async function actCindyMakeTest( if (action === 'build' && !cindyMakeTestController.isBuilding(sessionId)) { await cindyMakeTestController.stopTestForBuild(sessionId); const context = await load(sessionId, completionId); - const { getCindyMakeHistory, actCindyMakeHistory } = await import('./historyRuntime.js'); - const history = await getCindyMakeHistory(context.runId); + const { getCindyMakeHistory, actCindyMakeHistory, recoverHistoryBuildRollback } = + await import('./historyRuntime.js'); + let history = await getCindyMakeHistory(context.runId); if (!context.isCurrent()) throw makeTestError('unavailable'); if (history.busy) throw makeTestError('unavailable'); + if (captureMakeHistoryStore().readBuildRollback().length) { + await recoverHistoryBuildRollback(); + history = await getCindyMakeHistory(context.runId); + if (!context.isCurrent() || history.busy) throw makeTestError('unavailable'); + } if ( !['integrated', 'unchanged'].includes( history.items.find((item) => item.runId === context.runId)?.integration ?? '', ) ) { - const updated = await actCindyMakeHistory(context.runId, 'integrate'); + const item = history.items.find((item) => item.runId === context.runId); + const updated = await actCindyMakeHistory( + context.runId, + item?.actions?.includes('reapply') ? 'reapply' : 'integrate', + ); if ( updated.items.find((item) => item.runId === context.runId)?.integration !== 'integrated' ) @@ -388,7 +436,7 @@ export async function actCindyMakeTest( const code = (error as { code?: unknown })?.code; throwIpcError( 'PRECONDITION_FAILED', - code === 'environment' || code === 'changed' ? code : 'unavailable', + code === 'environment' || code === 'changed' || code === 'stopFailed' ? code : 'unavailable', ); } } diff --git a/apps/desktop/src/main/cindy-make/testTempDirectory.ts b/apps/desktop/src/main/cindy-make/testTempDirectory.ts new file mode 100644 index 0000000000..024cca988b --- /dev/null +++ b/apps/desktop/src/main/cindy-make/testTempDirectory.ts @@ -0,0 +1,38 @@ +import os from 'node:os'; +import path from 'node:path'; +import { lstat, mkdtemp, realpath } from 'node:fs/promises'; +import originalFs from 'original-fs'; + +/** Owns disposable launch files only; the stable test profile is never a cleanup target. */ +export async function createMakeTestTempDirectory(tempRoot = os.tmpdir()) { + const root = await realpath(tempRoot); + const directory = await mkdtemp(path.join(root, 'cindy-make-test-')); + const identity = await lstat(directory); + return { + directory, + async clean(): Promise { + let current; + try { + current = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw error; + } + // Reject a replaced directory or junction instead of following it into user data. + if ( + !current.isDirectory() || + current.isSymbolicLink() || + current.dev !== identity.dev || + current.ino !== identity.ino || + path.dirname(await realpath(directory)) !== root + ) + throw new Error('Make test temporary directory was replaced'); + await originalFs.promises.rm(directory, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 200, + }); + }, + }; +} diff --git a/apps/desktop/src/main/cindy-make/testWindowBehavior.ts b/apps/desktop/src/main/cindy-make/testWindowBehavior.ts new file mode 100644 index 0000000000..4b81b53cdb --- /dev/null +++ b/apps/desktop/src/main/cindy-make/testWindowBehavior.ts @@ -0,0 +1,23 @@ +/** The Make preview is an explicitly opened, disposable process, not a tray app. */ +export function createMakeTestWindowBehavior(options: { + isPackaged: boolean; + environment: NodeJS.ProcessEnv; + focus(): void; + quit(): void; +}) { + const enabled = + !options.isPackaged && + options.environment.XDT_CINDY_MAKE_TEST === '1' && + options.environment.XDT_ISOLATED === '1'; + return { + ready(): void { + if (enabled) options.focus(); + }, + close(event: { preventDefault(): void }): boolean { + if (!enabled) return false; + event.preventDefault(); + options.quit(); + return true; + }, + }; +} diff --git a/apps/desktop/src/main/cindy-make/upstreamMerge.ts b/apps/desktop/src/main/cindy-make/upstreamMerge.ts index 544f8b05bc..c67207962e 100644 --- a/apps/desktop/src/main/cindy-make/upstreamMerge.ts +++ b/apps/desktop/src/main/cindy-make/upstreamMerge.ts @@ -145,6 +145,58 @@ export async function cleanupMergedCandidate( return true; } +/** Discard only an unassigned update candidate, never the personal checkout or a task's work. */ +export async function cancelUpstreamMerge( + userData: string, + state: CindyMakeMergeState, + git: MergeGit, + isCurrent: () => boolean = () => true, +): Promise { + if ( + state.feature || + state.sessionId || + !['conflict', 'failed', 'cancelled'].includes(state.status) + ) + throw mergeError('unavailable'); + git = ownedGit(git, isCurrent); + const worktree = mergeWorktree(userData, state.id); + const source = await assertSource(userData, git); + const branchRef = 'refs/heads/' + mergeBranch(state.id); + const workspace = await lstat(worktree).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + }); + let tip: string; + if (workspace) { + await verifyMergeWorktree(userData, state, git); + if ( + (await gitOperationExists(git, worktree, 'rebase-merge')) || + (await gitOperationExists(git, worktree, 'rebase-apply')) + ) { + await git(['rebase', '--abort'], worktree); + } else if (await gitOperationExists(git, worktree, 'MERGE_HEAD')) { + await git(['merge', '--abort'], worktree); + } + await assertNoGitOperation(git, worktree); + tip = (await git(['rev-parse', 'HEAD'], worktree)).trim(); + // No force: unexpected edits, untracked files and worktree locks must survive. + await git(['worktree', 'remove', worktree], source); + } else { + // A crash may leave only the branch, or may have completed both cleanup steps. + tip = ( + await git(['rev-parse', '--verify', branchRef + '^{commit}'], source).catch((error) => { + if ((error as { exitCode?: number }).exitCode === 128) return ''; + throw error; + }) + ).trim(); + if (!tip) return; + } + if (!COMMIT.test(tip)) throw mergeError('unavailable'); + const worktrees = await git(['worktree', 'list', '--porcelain', '-z'], source); + if (worktrees.split('\0').includes('branch ' + branchRef)) throw mergeError('busy'); + await git(['update-ref', '--no-deref', '-d', branchRef, tip], source); +} + /** Rebase only in the retained candidate, then move the clean personal checkout to its result. */ export async function applyUpstreamMerge( userData: string, diff --git a/apps/desktop/src/main/cindy-make/upstreamMergeController.ts b/apps/desktop/src/main/cindy-make/upstreamMergeController.ts index 2cb3e92692..624e565d28 100644 --- a/apps/desktop/src/main/cindy-make/upstreamMergeController.ts +++ b/apps/desktop/src/main/cindy-make/upstreamMergeController.ts @@ -13,9 +13,16 @@ export function parseSavedUpstreamMerge(raw: string, userData: string): SavedUps if ( !state || typeof state.id !== 'string' || - !['fetching', 'merging', 'conflict', 'resolving', 'checking', 'merged', 'failed'].includes( - state.status, - ) || + ![ + 'fetching', + 'merging', + 'conflict', + 'resolving', + 'checking', + 'merged', + 'failed', + 'cancelled', + ].includes(state.status) || typeof state.ref !== 'string' || typeof state.upstreamCommit !== 'string' || (state.upstreamCommit !== '' && !/^[0-9a-f]{40}$/i.test(state.upstreamCommit)) || @@ -23,6 +30,8 @@ export function parseSavedUpstreamMerge(raw: string, userData: string): SavedUps (state.strategy !== undefined && state.strategy !== 'rebase') || (state.rebaseBase !== undefined && !/^[0-9a-f]{40}$/i.test(state.rebaseBase)) || (state.rebaseReview !== undefined && typeof state.rebaseReview !== 'boolean') || + (state.cancellationRequested !== undefined && + typeof state.cancellationRequested !== 'boolean') || (state.feature !== undefined && (!validFeaturePlan(state.feature) || !saved.sessionOwner)) || (state.tree !== undefined && !/^[0-9a-f]{40,64}$/i.test(state.tree)) || (state.baselineCommit !== undefined && !/^[0-9a-f]{40}$/i.test(state.baselineCommit)) || @@ -89,6 +98,7 @@ export interface UpstreamMergeDependencies { running: (sessionId: string) => boolean; refresh: () => Promise; cleanup: (state: CindyMakeMergeState) => Promise; + cancel: (state: CindyMakeMergeState, isCurrent: () => boolean) => Promise; } const errors = new Set([ 'busy', @@ -100,6 +110,7 @@ const errors = new Set([ 'checksFailed', 'interrupted', 'startFailed', + 'cancelFailed', ]); /** Device-local Git operation, with an account-bound resolution task. Never resumes writes on boot. */ @@ -113,9 +124,11 @@ export class UpstreamMergeController { const recovered = { ...state, hasWorkspace: deps.hasWorkspace(state), - ...(['fetching', 'merging', 'checking'].includes(state.status) - ? { status: 'failed' as const, error: 'interrupted' as const } - : {}), + ...(state.cancellationRequested + ? { status: 'failed' as const, error: 'cancelFailed' as const } + : ['fetching', 'merging', 'checking'].includes(state.status) + ? { status: 'failed' as const, error: 'interrupted' as const } + : {}), }; // A completed merge removes its candidate worktree. Recompute this bit on // every restore so an older state file cannot keep the UI looking busy. @@ -199,27 +212,39 @@ export class UpstreamMergeController { } return this.status(); } - async update(options?: CindyMakeTaskOptions): Promise { + async update(_options?: CindyMakeTaskOptions): Promise { const owner = this.deps.owner(); return this.run(async () => { - if (this.saved?.state.hasWorkspace && this.saved.state.status !== 'merged') return; - this.saved = undefined; - this.save({ id: randomUUID(), status: 'fetching', ref: '', upstreamCommit: '' }); + if ( + this.saved?.state.cancellationRequested || + (this.saved?.state.hasWorkspace && this.saved.state.status !== 'merged') + ) + return; + this.saved = { + sessionOwner: owner || undefined, + state: { id: randomUUID(), status: 'fetching', ref: '', upstreamCommit: '' }, + }; + this.save(this.saved.state); const latest = await this.deps.latest(); this.save({ ...this.saved!.state, ref: latest.ref, upstreamCommit: latest.commit }); const result = await this.deps.prepare(this.saved!.state, async (next) => this.save(next)); this.save(result); - if (result.status === 'conflict' && owner && this.deps.owner() === owner) { - await this.createResolutionTask(options, owner); - } + // Conflicts wait for an explicit resolve/cancel decision before any Agent task exists. if (result.status === 'merged') await this.cleanupCompleted(result); await this.deps.refresh().catch(() => undefined); }); } - async resolve(options?: CindyMakeTaskOptions): Promise { + async resolve( + options?: CindyMakeTaskOptions, + operationId?: string, + ): Promise { const owner = this.deps.owner(); + if (operationId !== undefined && this.saved?.state.id !== operationId) throw mergeError('busy'); + if (this.saved?.state.status === 'cancelled' || this.saved?.state.cancellationRequested) + throw mergeError('busy'); if (this.saved?.sessionOwner && this.saved.sessionOwner !== owner) throw mergeError('busy'); return this.run(async () => { + if (this.deps.owner() !== owner) throw mergeError('busy'); if (this.saved?.state.feature && !this.saved.state.feature.awaitingResolution) { const isCurrent = () => this.deps.owner() === owner; let result: CindyMakeMergeState; @@ -252,6 +277,40 @@ export class UpstreamMergeController { await this.createResolutionTask(options, owner); }); } + async cancel(operationId: string): Promise { + const state = this.saved?.state; + const owner = this.deps.owner(); + if ( + this.active || + !state || + state.id !== operationId || + state.feature || + state.sessionId || + !['conflict', 'failed', 'cancelled'].includes(state.status) || + (this.saved?.sessionOwner && this.saved.sessionOwner !== owner) + ) + throw mergeError('busy'); + return this.run(async () => { + const isCurrent = () => this.saved?.state.id === operationId && this.deps.owner() === owner; + if (!isCurrent()) throw mergeError('busy'); + if (state.status === 'cancelled' && !state.hasWorkspace) return; + this.save({ ...state, cancellationRequested: true }); + try { + await this.deps.cancel(state, isCurrent); + } catch { + // Retain the decision and recovery entry if Git could not reclaim the candidate. + throw mergeError('cancelFailed'); + } + this.save({ + ...state, + status: 'cancelled', + hasWorkspace: false, + cancellationRequested: undefined, + error: undefined, + }); + await this.deps.refresh().catch(() => undefined); + }); + } async feature( plan: MakeFeatureMergePlan, options?: CindyMakeTaskOptions, @@ -261,6 +320,7 @@ export class UpstreamMergeController { if ( !owner || this.active || + this.saved?.state.cancellationRequested || (this.saved?.state.hasWorkspace && this.saved.state.status !== 'merged') ) throw mergeError('busy'); @@ -297,7 +357,8 @@ export class UpstreamMergeController { owner: string, ): Promise { const state = this.saved?.state; - if (!state?.hasWorkspace || state.status === 'merged') throw mergeError('unavailable'); + if (!state?.hasWorkspace || ['merged', 'cancelled'].includes(state.status)) + throw mergeError('unavailable'); const isCurrent = () => this.deps.owner() === owner; if (!isCurrent()) throw mergeError('busy'); const id = await this.deps.session( diff --git a/apps/desktop/src/main/cindy-make/upstreamMergeRuntime.ts b/apps/desktop/src/main/cindy-make/upstreamMergeRuntime.ts index 27161a9531..54b3c9deeb 100644 --- a/apps/desktop/src/main/cindy-make/upstreamMergeRuntime.ts +++ b/apps/desktop/src/main/cindy-make/upstreamMergeRuntime.ts @@ -30,6 +30,7 @@ import { prepareFeatureMerge, applyFeatureMerge, cleanupMergedCandidate, + cancelUpstreamMerge, type MergeGit, } from './upstreamMerge.js'; @@ -150,6 +151,8 @@ export function configureUpstreamMerge(isRunning: (id: string) => boolean): void }, running: isRunning, refresh, + cancel: async (state, isCurrent) => + cancelUpstreamMerge(userData, state, await git(), isCurrent), cleanup: async (state) => { if (state.sessionId) return; // Only reclaim the exact file tree already adopted by the personal checkout. @@ -180,9 +183,16 @@ export function configureUpstreamMerge(isRunning: (id: string) => boolean): void export async function actUpstreamMerge(raw: unknown): Promise { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throwIpcError('INVALID_PARAMS', 'Invalid upstream merge request'); - const { action, createOptions } = raw as Record; - if (!['update', 'resolve', 'status'].includes(String(action))) + const { action, createOptions, operationId } = raw as Record; + if (!['update', 'resolve', 'cancel', 'status'].includes(String(action))) throwIpcError('INVALID_PARAMS', 'Invalid upstream merge action'); + if ( + (operationId !== undefined && + (typeof operationId !== 'string' || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(operationId))) || + (action === 'cancel' && operationId === undefined) + ) + throwIpcError('INVALID_PARAMS', 'Invalid upstream merge operation'); const options = validateCindyMakeTaskStart({ runId: 'merge', request: 'merge', @@ -195,8 +205,10 @@ export async function actUpstreamMerge(raw: unknown): Promise { + if (selection) await selectVersion(profile, selection); + if ( + currentId === 'original' && + !request && + fs.existsSync(path.join(versionsRoot(profile), 'original.json')) + ) + await rememberOriginalVersion(); + })().catch((error) => { + log.warn('Failed to update the recorded original version', { + error: error instanceof Error ? error.message : String(error), + }); + }); +} /** Helper is a separate headless Electron process, so it survives normal app shutdown without RunAsNode. */ export async function startVersionHandoff( @@ -397,7 +428,10 @@ export async function startVersionHandoff( ): Promise { if (switching) throw versionError('busy'); const profile = currentVersionProfile(); - const original = readOriginalVersion(profile.userData); + // Startup dispatch precedes the asynchronous original.json refresh. Use this + // process's identity so an upgrade cannot launch a now-incompatible personal app. + const original = + currentId === 'original' ? describeOriginalVersion() : readOriginalVersion(profile.userData); if (!original) throw versionError('unavailable'); if (targetId !== 'original') await verifyPersonalVersion(profile.userData, targetId, original); else if ( @@ -429,6 +463,8 @@ export async function startVersionHandoff( const pending = path.join(versionsRoot(profile.userData), 'pending.json'); const previous = readVersionJson<{ id: string; pid: number }>(pending); if (previous && pidAlive(previous.pid)) throw versionError('busy'); + if (currentId === 'original') + writeVersionJson(path.join(versionsRoot(profile.userData), 'original.json'), original); writeVersionJson(versionRequestPath(profile.userData, item.id), item); writeVersionJson(pending, { id: item.id, pid: process.pid }); }); @@ -612,7 +648,42 @@ async function runVersionHelper(item: VersionLaunchRequest): Promise { } } -/** Called after dev profile resolution, before the database, single-instance lock and main windows. */ +/** + * A handoff that failed after Electron became ready cannot fall back to opening the original in + * this process: bootstrap-electron is not loaded yet and its pre-ready registrations would be + * rejected. Reset the selection so the next launch opens the original, then leave. + */ +async function recoverOriginalAfterReady(profile: string): Promise { + await selectVersion(profile, 'original').catch(() => {}); + if (app.isPackaged) { + app.relaunch({ + args: [ + ...process.argv.slice(1).filter((arg) => arg !== RESTORE_ORIGINAL_FLAG), + RESTORE_ORIGINAL_FLAG, + ], + }); + app.exit(0); + return true; + } + // Dev cannot relaunch itself: Forge/Vite exit with this process. The dev runner reports the + // exit; the reset selection makes the next start open the original. + process.stderr.write( + '[cindy] personal version handoff failed after Electron became ready; ' + + 'the selection was reset to the original. Start Dev again.\n', + ); + app.exit(1); + return true; +} + +/** + * Called after dev profile resolution, before the database, single-instance lock and main windows. + * + * Every path that returns false continues into bootstrap-electron, whose module top level + * registers privileged schemes and the 'ready' listener; both require Electron not to be ready + * yet. Those paths therefore never yield to the event loop: registry reads are synchronous, + * the personal-version self-check hashes synchronously, and registry writes are deferred to + * finishCindyVersionStartup(). Only paths that end in app.exit() may await real I/O. + */ export async function dispatchCindyVersionStartup(): Promise { if (helper && request) { try { @@ -649,14 +720,13 @@ export async function dispatchCindyVersionStartup(): Promise { } setCindyVersionLockScope('profile'); } + const restoreOriginal = process.argv.includes(RESTORE_ORIGINAL_FLAG); if (currentId !== 'original') { const original = readOriginalVersion(profile); if (!original) throw versionError('unavailable'); - await verifyPersonalVersion(profile, currentId, original); + verifyPersonalVersionSync(profile, currentId, original); if (!request) { - const selected = process.argv.includes('--cindy-version-original') - ? 'original' - : selectedVersion(profile); + const selected = restoreOriginal ? 'original' : selectedVersion(profile); if (selected !== currentId) { await startVersionHandoff(selected); app.exit(0); @@ -669,19 +739,22 @@ export async function dispatchCindyVersionStartup(): Promise { !request && fs.existsSync(path.join(versionsRoot(profile), 'original.json')) ) { + if (restoreOriginal) deferredSelection = 'original'; try { - await rememberOriginalVersion(); - if (process.argv.includes('--cindy-version-original')) - await selectVersion(profile, 'original'); - const selected = selectedVersion(profile); + const selected = restoreOriginal ? 'original' : selectedVersion(profile); if (selected !== 'original') { await startVersionHandoff(selected); app.exit(0); return true; } - } catch { - await selectVersion(profile, 'original').catch(() => {}); - log.warn('Personal version selection unavailable; opening the original'); + } catch (error) { + deferredSelection = 'original'; + log.warn('Personal version selection unavailable; opening the original', { + error: error instanceof Error ? error.message : String(error), + }); + // A failure before the handoff's first real I/O (unreadable selection, missing version + // directory) leaves Electron not ready, so the original can still open in this process. + if (app.isReady()) return recoverOriginalAfterReady(profile); } } return false; diff --git a/apps/desktop/src/main/cindy-make/versionStore.ts b/apps/desktop/src/main/cindy-make/versionStore.ts index ddbe7cc72d..b155daeb61 100644 --- a/apps/desktop/src/main/cindy-make/versionStore.ts +++ b/apps/desktop/src/main/cindy-make/versionStore.ts @@ -169,6 +169,20 @@ export function readPersonalVersion(profile: string, id: string): PersonalVersio } return item; } +/** A published snapshot is durable even if history registration was interrupted. */ +export function hasPublishedPersonalVersionCommit(profile: string, commit: string): boolean { + const root = path.join(versionsRoot(profile), 'versions'); + if (!fs.existsSync(root)) return false; + assertVersionDirectory(profile, root); + return fs.readdirSync(root).some((id) => { + if (!VERSION_ID.test(id) || !fs.existsSync(path.join(root, id, 'version.json'))) return false; + try { + return readPersonalVersion(profile, id).commit === commit; + } catch { + return false; + } + }); +} export function migrationIdentity(directory: string): string { return createHash('sha256') .update(JSON.stringify(createMigrationRuntimeManifest(directory).migrations)) @@ -179,6 +193,22 @@ export async function fileDigest(file: string): Promise { for await (const data of originalFs.createReadStream(file)) hash.update(data); return hash.digest('hex'); } +/** Same digest as fileDigest without touching the event loop; only for the pre-ready startup path. */ +export function fileDigestSync(file: string): string { + const hash = createHash('sha256'); + const fd = originalFs.openSync(file, 'r'); + try { + const chunk = Buffer.allocUnsafe(1024 * 1024); + for (;;) { + const read = originalFs.readSync(fd, chunk, 0, chunk.length, null); + if (read === 0) break; + hash.update(chunk.subarray(0, read)); + } + } finally { + originalFs.closeSync(fd); + } + return hash.digest('hex'); +} export function runnableBundlePaths( directory: string, appName: string, @@ -223,11 +253,7 @@ export function publishPersonalVersion(profile: string, id: string): void { fs.unlinkSync(path.join(directory, 'staged.json')); } catch {} } -export async function verifyPersonalVersion( - profile: string, - id: string, - original: OriginalVersion, -): Promise { +function personalVersionVerification(profile: string, id: string, original: OriginalVersion) { const item = readPersonalVersion(profile, id); const root = versionDirectory(profile, id); if ( @@ -239,13 +265,49 @@ export async function verifyPersonalVersion( const resources = path.join(root, item.resources); if ( readVersionJson<{ version: number }>(path.join(resources, 'cindy-version-protocol.json')) - ?.version !== CINDY_VERSION_PROTOCOL || - (await fileDigest(path.join(root, item.executable))) !== item.executableHash || - (await fileDigest(path.join(resources, 'app.asar'))) !== item.applicationHash || - migrationIdentity(path.join(resources, 'drizzle')) !== item.migrationHash + ?.version !== CINDY_VERSION_PROTOCOL ) throw versionError('unavailable'); - return item; + return { + executable: path.join(root, item.executable), + application: path.join(resources, 'app.asar'), + finish(executableHash: string, applicationHash: string): PersonalVersion { + if ( + executableHash !== item.executableHash || + applicationHash !== item.applicationHash || + migrationIdentity(path.join(resources, 'drizzle')) !== item.migrationHash + ) + throw versionError('unavailable'); + return item; + }, + }; +} +export async function verifyPersonalVersion( + profile: string, + id: string, + original: OriginalVersion, +): Promise { + const verification = personalVersionVerification(profile, id, original); + return verification.finish( + await fileDigest(verification.executable), + await fileDigest(verification.application), + ); +} +/** + * Startup self-check of a launched personal version. It runs before bootstrap-electron is + * loaded, and that module must still see Electron as not ready, so the digests are computed + * without yielding to the event loop. + */ +export function verifyPersonalVersionSync( + profile: string, + id: string, + original: OriginalVersion, +): PersonalVersion { + const verification = personalVersionVerification(profile, id, original); + return verification.finish( + fileDigestSync(verification.executable), + fileDigestSync(verification.application), + ); } export async function withVersionStore(profile: string, run: () => Promise): Promise { const root = versionsRoot(profile); diff --git a/apps/desktop/src/main/localDb/__tests__/sessionActiveTurn.test.ts b/apps/desktop/src/main/localDb/__tests__/sessionActiveTurn.test.ts index b26e968a0d..e38605d41f 100644 --- a/apps/desktop/src/main/localDb/__tests__/sessionActiveTurn.test.ts +++ b/apps/desktop/src/main/localDb/__tests__/sessionActiveTurn.test.ts @@ -548,6 +548,46 @@ describe('sessionActiveTurn', () => { expect(await hasAssistantProgressAfterMessage('s-same-ms', 'c-user2')).toBe(true); }); + it('restores native Make failures and drops them only when handled or no longer visible', async () => { + const { listErrorTailPendingSessionIds } = await import('../sessionActiveTurn.js'); + const client = createTestDbClient(); + const at = Date.now(); + const insert = async (id: string, completion: unknown, options: { + source?: string; status?: string; clearedAt?: number; rewindAt?: number; content?: unknown; + } = {}) => { + await seedSession(client, id, { source: options.source ?? 'cindy-make', ...options }); + await client.exec( + 'INSERT INTO messages (id, client_id, session_id, role, content, agent_meta, created_at, rewind_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, id, id, 'assistant', JSON.stringify(options.content ?? ''), + JSON.stringify({ cindyMakeCompletion: completion }), at, options.rewindAt ?? null], + ); + }; + const failed = { reportedAt: at, lastAction: 'build', personal: { status: 'failed', error: 'buildFailed' } }; + await insert('make-failed', failed); + await insert('make-test-failed', { reportedAt: at, lastAction: 'test', test: { status: 'failed' } }); + await insert('make-interrupted', { reportedAt: at, lastAction: 'test', test: { status: 'stopped', error: 'interrupted' } }); + await insert('make-preparation', null, { content: { __cindyMakeCard: { type: 'cindy-make', + data: { report: { runId: 'run', status: 'failed' } } } } }); + await insert('make-continued', { ...failed, continuedAt: at + 1 }); + await insert('make-cancelled', { ...failed, personal: { status: 'failed', error: 'cancelled' } }); + await insert('make-retried', { ...failed, personal: { status: 'checking' } }); + await insert('make-new-test', { ...failed, lastAction: 'test', test: { status: 'ready' } }); + await insert('make-rewound', failed, { rewindAt: at + 1 }); + await insert('make-cleared', failed, { clearedAt: at }); + await insert('make-archived', failed, { status: 'archived' }); + await insert('ordinary-task', failed, { source: 'desktop' }); + await insert('make-later-user', failed); + await client.exec('INSERT INTO messages (id, client_id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?, ?)', + ['later', 'later', 'make-later-user', 'user', '{}', at]); + expect((await listErrorTailPendingSessionIds()).sort()).toEqual([ + 'make-failed', 'make-interrupted', 'make-preparation', 'make-test-failed', + ]); + await client.exec('UPDATE messages SET agent_meta = ? WHERE id = ?', [ + JSON.stringify({ cindyMakeCompletion: { ...failed, personal: { status: 'waiting' } } }), 'make-failed', + ]); + expect(await listErrorTailPendingSessionIds()).not.toContain('make-failed'); + }); + it('listErrorTailPendingSessionIds matches undismissed error tails only', async () => { const { listErrorTailPendingSessionIds } = await import('../sessionActiveTurn.js'); const client = createTestDbClient(); diff --git a/apps/desktop/src/main/localDb/cindyMakeAttention.ts b/apps/desktop/src/main/localDb/cindyMakeAttention.ts new file mode 100644 index 0000000000..4103d6bdca --- /dev/null +++ b/apps/desktop/src/main/localDb/cindyMakeAttention.ts @@ -0,0 +1,37 @@ +import { and, eq, gt, isNull, sql } from 'drizzle-orm'; +import { getCindyMakeMessageAttention } from '../../shared/cindyMakeAttention.js'; +import { getDbClient } from './client/current.js'; +import { messages, sessions } from './schema.js'; + +/** Restore unresolved native Make failures even when their task has never been opened. */ +export async function listCindyMakePendingFailureSessionIds(): Promise { + const rows = await getDbClient() + .drizzle.select({ + sessionId: messages.sessionId, + role: messages.role, + content: messages.content, + agentMeta: messages.agentMeta, + }) + .from(messages) + .innerJoin(sessions, eq(sessions.id, messages.sessionId)) + .where( + and( + eq(sessions.source, 'cindy-make'), + eq(sessions.status, 'active'), + eq(messages.role, 'assistant'), + isNull(messages.rewindAt), + gt(messages.createdAt, sql`COALESCE(${sessions.clearedAt}, 0)`), + sql`NOT EXISTS ( + SELECT 1 FROM messages m2 + WHERE m2.session_id = ${messages.sessionId} + AND m2.rewind_at IS NULL + AND (m2.created_at > ${messages.createdAt} + OR (m2.created_at = ${messages.createdAt} + AND m2.rowid > ${sql.raw('"messages"."rowid"')})) + )`, + ), + ); + return rows + .filter((row) => getCindyMakeMessageAttention(row)?.kind === 'error') + .map((row) => row.sessionId); +} diff --git a/apps/desktop/src/main/localDb/sessionActiveTurn.ts b/apps/desktop/src/main/localDb/sessionActiveTurn.ts index c8845ac31d..8464d7d020 100644 --- a/apps/desktop/src/main/localDb/sessionActiveTurn.ts +++ b/apps/desktop/src/main/localDb/sessionActiveTurn.ts @@ -52,6 +52,7 @@ import { and, desc, eq, gt, inArray, isNull, lt, sql } from 'drizzle-orm'; import { getDbClient } from './client/current'; +import { listCindyMakePendingFailureSessionIds } from './cindyMakeAttention'; import { getSessionInterruptionBootAt, setSessionInterruptionBootAtForTests, @@ -440,10 +441,13 @@ export async function listErrorTailPendingRows(): Promise< return rows; } -/** 同上,只要会话 id —— 红点派生用。 */ +/** Native Make failures use the same unresolved-alert projection, not synthetic error rows. */ export async function listErrorTailPendingSessionIds(): Promise { - const rows = await listErrorTailPendingRows(); - return [...new Set(rows.map((r) => r.sessionId))]; + const [rows, makeFailures] = await Promise.all([ + listErrorTailPendingRows(), + listCindyMakePendingFailureSessionIds(), + ]); + return [...new Set([...rows.map((r) => r.sessionId), ...makeFailures])]; } /** diff --git a/apps/desktop/src/renderer/__tests__/makerChatStoreCindyMakeAttention.test.ts b/apps/desktop/src/renderer/__tests__/makerChatStoreCindyMakeAttention.test.ts new file mode 100644 index 0000000000..af458bba49 --- /dev/null +++ b/apps/desktop/src/renderer/__tests__/makerChatStoreCindyMakeAttention.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setDataOwnerGeneration } from '@/contexts/dataOwnerGeneration'; +import { makerChatStore } from '@/lib/makerChatStore'; +import { clearSessionAttention, getSessionAttentionKind } from '@/lib/sessionAttentionStore'; + +vi.mock('@/lib/sessionsBus', () => ({ emitPatch: vi.fn() })); + +const sessionId = 'make-ingress'; +let created: (payload: unknown, ownerStamp?: unknown) => void; +let remote: (payload: unknown) => void; +let leave: (() => void) | undefined; + +function message(status: string, overrides: Record = {}) { + return { + id: 'completion', + clientId: 'completion', + sessionId, + role: 'assistant', + content: '', + createdAt: '2026-09-20T01:00:00.000Z', + agentMeta: { + cindyMakeCompletion: { + reportedAt: 100, + lastAction: 'build', + personal: { status, buildId: 'build' }, + }, + }, + ...overrides, + }; +} + +beforeEach(() => { + setDataOwnerGeneration('make-owner', 1); + const subscribe = () => () => {}; + vi.stubGlobal('window', { + electronAPI: { + maker: { + onEvent: subscribe, + onStatusChanged: subscribe, + onInputProjection: subscribe, + onInteractionRequest: subscribe, + onInteractionDismissed: subscribe, + }, + localDb: { + messages: { + onCreated: (cb: typeof created) => { + created = cb; + return () => {}; + }, + }, + }, + deviceLink: { + onRemotePush: (cb: typeof remote) => { + remote = cb; + return () => {}; + }, + }, + onUsageMessageTurnCost: subscribe, + }, + }); + makerChatStore.initGlobalListeners(); +}); + +afterEach(() => { + leave?.(); + leave = undefined; + makerChatStore.purgeSession(sessionId); + makerChatStore.__teardownGlobalListeners(); + clearSessionAttention(sessionId, { intent: 'explicit' }); + setDataOwnerGeneration(null); + vi.unstubAllGlobals(); +}); + +describe('Make result dots at the real message ingress', () => { + it.each(['local', 'remote'])( + 'updates %s dots without mounting the card or running an Agent', + (origin) => { + const push = (status: string) => { + const payload = { sessionId, message: message(status) }; + if (origin === 'local') created(payload); + else remote({ channel: 'local-db:messages:created', payload }); + }; + push('packaging'); + push('failed'); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + clearSessionAttention(sessionId); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + push('waiting'); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + push('ready'); + expect(getSessionAttentionKind(sessionId)).toBe('done'); + clearSessionAttention(sessionId); + push('ready'); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + expect(makerChatStore.getSnapshot(sessionId).messages).toHaveLength(1); + }, + ); + + it('does not mark a viewed success unread, but does mark a viewed failure', () => { + leave = makerChatStore.enterView(sessionId); + created({ sessionId, message: message('ready') }); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + created({ sessionId, message: message('failed') }); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + }); + + it('ignores old-account pushes and updates to a card before a later user message', () => { + created( + { sessionId, message: message('failed') }, + { dataOwnerId: 'old-owner', ownerGeneration: 0 }, + ); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + created({ sessionId, message: message('packaging') }); + created({ + sessionId, + message: message('packaging', { + id: 'new-user', + clientId: 'new-user', + role: 'user', + content: 'continue', + agentMeta: null, + }), + }); + created({ sessionId, message: message('failed') }); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/__tests__/sessionTitleProjectionOutlets.test.ts b/apps/desktop/src/renderer/__tests__/sessionTitleProjectionOutlets.test.ts index 72720cf382..9afd168404 100644 --- a/apps/desktop/src/renderer/__tests__/sessionTitleProjectionOutlets.test.ts +++ b/apps/desktop/src/renderer/__tests__/sessionTitleProjectionOutlets.test.ts @@ -31,7 +31,7 @@ const draftRoute = read('features/cc-agent/NewMakerDraftRoute.tsx'); describe('desktop 会话标题投影出口', () => { it('rail 置顶瓷砖、aria-label 与悬浮预览卡都用显示标题', () => { expect(railNav).toContain( - "const displayTitle = getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'));", + "const displayTitle = getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'), t);", ); expect(railNav).toContain('aria-label={displayTitle}'); expect(railNav).toContain('{pinnedTileLabel(displayTitle)}'); diff --git a/apps/desktop/src/renderer/__tests__/useSessionRunningStatusSilence.test.ts b/apps/desktop/src/renderer/__tests__/useSessionRunningStatusSilence.test.ts index 8860a548a4..5ef96104b8 100644 --- a/apps/desktop/src/renderer/__tests__/useSessionRunningStatusSilence.test.ts +++ b/apps/desktop/src/renderer/__tests__/useSessionRunningStatusSilence.test.ts @@ -488,6 +488,21 @@ describe('useSessionRunningStatus silenced completion handling', () => { expect(onSessionDone).not.toHaveBeenCalled(); }); + it('does not replace a native Make failure with delayed Agent success attention', async () => { + vi.useFakeTimers(); + renderHook(() => useSessionRunningStatus(undefined)); + await emitSnapshot(new Map([['make', status(true)]])); + await emitSnapshot(new Map([['make', status(false)]])); + // The native build failed after the Agent finished: there is no Agent error row. + vi.mocked(getSessionAttentionKind).mockReturnValue('error'); + try { + await act(async () => { await vi.advanceTimersByTimeAsync(500); }); + expect(addSessionAttention).not.toHaveBeenCalledWith('make', 'done'); + } finally { + vi.mocked(getSessionAttentionKind).mockReturnValue(undefined); + } + }); + it('preserves the prior done attention if the next turn stays in starting', async () => { vi.useFakeTimers(); const onSessionDone = vi.fn(); diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakeBuildLog.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakeBuildLog.tsx new file mode 100644 index 0000000000..b24e3b9c1c --- /dev/null +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakeBuildLog.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { CindyMakePersonalBuildState } from '../../../shared/cindyMakeSession'; + +/** A compact, localizable timeline of build stages; it deliberately omits raw process output. */ +export function CindyMakeBuildLog({ + build, + openWhileActive = false, +}: { + build?: CindyMakePersonalBuildState; + openWhileActive?: boolean; +}) { + const { t, i18n } = useTranslation(); + const entries = build?.logs ?? []; + const [open, setOpen] = useState(openWhileActive); + useEffect(() => { + if (openWhileActive) setOpen(true); + }, [openWhileActive]); + if (!entries.length) return null; + return ( +
setOpen(event.currentTarget.open)} + className="rounded-xl border border-[var(--border-default)] bg-[var(--surface-card)] px-3 py-2" + > + + {t('cindyMake.personal.buildLog.title')} · {entries.length} + +
    + {entries.map((entry, index) => ( +
  1. + + {t('cindyMake.personal.buildLog.steps.' + entry.step)} +
  2. + ))} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakeHistoryPanel.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakeHistoryPanel.tsx index c11197e15a..eda1ea226d 100644 --- a/apps/desktop/src/renderer/components/cindy-make/CindyMakeHistoryPanel.tsx +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakeHistoryPanel.tsx @@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'; import type { CindyMakeHistoryState, MakeHistoryAction } from '../../../shared/cindyMakeHistory'; import './cindyMakeTasks.css'; import { CindyMakeTestStep } from './CindyMakeTestStep'; +import { CindyMakeBuildLog } from './CindyMakeBuildLog'; type Filter = 'all' | 'pending' | 'integrated' | 'ended'; /** Historical facts and allowed actions come from Main; an old button cannot authorize a write. */ @@ -123,27 +124,56 @@ export function CindyMakeHistoryPanel({ item.lifecycle !== 'ended'), ); const selected = visible.find((item) => item.runId === selectedId) ?? visible[0]; + const currentBuild = (item: CindyMakeHistoryState['items'][number]) => + item.operation === 'build' || + (!item.operation && ['ready', 'ended'].includes(item.lifecycle) && !item.test) + ? item.build + : undefined; const statusKey = (item: CindyMakeHistoryState['items'][number]) => { - if ( - item.build && - (item.operation === 'build' || - (item.lifecycle === 'ready' && item.completions.at(-1)?.lastAction === 'build')) - ) - return item.build.stopping + if (item.conflict) return 'cindyMake.history.conflict'; + if (item.operationError) return 'cindyMake.history.actionFailed'; + if (item.operation && item.operation !== 'build') return 'cindyMake.history.working'; + const build = currentBuild(item); + if (build) + return build.stopping ? 'cindyMake.history.stopping' - : item.build.status === 'checking' && item.build.checkStep - ? 'cindyMake.personal.checkStep.' + item.build.checkStep - : 'cindyMake.personal.status.' + item.build.status; + : build.status === 'waiting' && build.preparationStep + ? 'cindyMake.personal.preparationStep.' + build.preparationStep + : build.status === 'checking' && build.checkStep + ? 'cindyMake.personal.checkStep.' + build.checkStep + : 'cindyMake.history.buildStatus.' + build.status; return item.test ? 'cindyMake.test.status.' + item.test.status : 'cindyMake.history.lifecycle.' + item.lifecycle; }; - const versionStatus = (item: CindyMakeHistoryState['items'][number]) => - item.needsBuild - ? t('cindyMake.history.versionPending') - : item.versions.length - ? t('cindyMake.history.versionReady') - : undefined; + const selectedBuild = selected && currentBuild(selected); + const selectedError = + selected?.conflict || selected?.operationError + ? undefined + : selectedBuild?.status === 'failed' && selectedBuild.error + ? 'cindyMake.personal.errors.' + selectedBuild.error + : selected?.test?.error + ? 'cindyMake.test.errors.' + selected.test.error + : undefined; + const selectedActions: MakeHistoryAction[] = selected + ? ( + [ + 'build', + 'test', + 'continue', + 'open', + 'resolve', + 'retry', + 'retry-prepare', + 'retry-cleanup', + ] as const + ).filter( + (action) => + selected.actions.includes(action) && + !(action === 'open' && selected.actions.includes('continue')), + ) + : []; + if (selected?.canHide && !selectedActions.includes('retry-cleanup')) selectedActions.push('hide'); useEffect(() => { const nextId = selected?.runId ?? ''; if (nextId !== selectedId) select(nextId); @@ -234,9 +264,7 @@ export function CindyMakeHistoryPanel({ }); if (!accepted || !isDataOwnerGenerationCurrent(owner)) return; } - if (action === 'build') { - update(await window.electronAPI.generateCindyMakePersonal()); - } else if (action === 'retry-prepare') { + if (action === 'retry-prepare') { await window.electronAPI.startCindyMakeTask({ runId: selected.runId, request: selected.request, @@ -282,9 +310,11 @@ export function CindyMakeHistoryPanel({ ? reason : undefined; toast.error( - knownReason - ? t('settings.cindyMake.tasks.errors.' + knownReason) - : t('cindyMake.history.actionFailed'), + reason === 'stopFailed' + ? t('cindyMake.test.errors.stopFailed') + : knownReason + ? t('settings.cindyMake.tasks.errors.' + knownReason) + : t('cindyMake.history.actionFailed'), ); await refresh(); } @@ -296,6 +326,10 @@ export function CindyMakeHistoryPanel({ } }; const building = !!state?.build && !['ready', 'failed'].includes(state.build.status); + const showGlobalResult = + state?.build && + !building && + !items.some((item) => state.build?.buildId && item.build?.buildId === state.build.buildId); const stopping = building && state?.build?.stopping === true; const buildStep = (value: NonNullable) => { if (value.status === 'waiting') return 1; @@ -396,16 +430,16 @@ export function CindyMakeHistoryPanel({ {t( stopping ? 'cindyMake.history.stopping' - : 'cindyMake.history.buildStatus.' + state.build.status, - )} -

-

- {t( - state.build.status === 'checking' && state.build.checkStep - ? 'cindyMake.personal.checkStep.' + state.build.checkStep - : 'cindyMake.history.buildStatus.' + state.build.status, + : state.build.status === 'waiting' && state.build.preparationStep + ? 'cindyMake.personal.preparationStep.' + state.build.preparationStep + : 'cindyMake.history.buildStatus.' + state.build.status, )}

+ {state.build.status === 'checking' && state.build.checkStep && ( +

+ {t('cindyMake.personal.checkStep.' + state.build.checkStep)} +

+ )} diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakeMergeNotice.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakeMergeNotice.tsx index 742df036e2..6d139d17b3 100644 --- a/apps/desktop/src/renderer/components/cindy-make/CindyMakeMergeNotice.tsx +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakeMergeNotice.tsx @@ -1,66 +1,49 @@ -import { useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; import { Tip } from '@/components/ui/tooltip'; import { Spinner } from '@/components/ui/spinner'; -import { toast } from '@/lib/toast'; -import { isSelectableVendor } from '@/lib/agentVendors'; -import { - getDataOwnerGeneration, - isDataOwnerGenerationCurrent, -} from '@/contexts/dataOwnerGeneration'; +import { useCindyMakeMergeResolution } from './useCindyMakeMergeResolution'; import type { CindyMakeMergeState } from '../../../shared/cindyMakeMerge'; -function ResolveMergeButton({ state }: { state: CindyMakeMergeState }) { +function ResolveMergeButton({ + state, + disabled, +}: { + state: CindyMakeMergeState; + disabled?: boolean; +}) { const { t } = useTranslation(); - const navigate = useNavigate(); - const submitting = useRef(false); - const [busy, setBusy] = useState(false); - const resolve = async () => { - if (submitting.current) return; - const owner = getDataOwnerGeneration(); - submitting.current = true; - setBusy(true); - try { - const draftState = await import('@/state/newMakerDraft'); - if (!isDataOwnerGenerationCurrent(owner)) return; - const draft = draftState.getDraft(); - const vendor = isSelectableVendor(draft.vendor) ? draft.vendor : 'cc'; - const prefs = draft.lastByVendor[vendor]; - const result = await window.electronAPI.cindyMakeMerge({ - action: 'resolve', - createOptions: { - agentKind: vendor, - model: prefs.model, - effort: prefs.effort, - providerId: prefs.providerId, - permissionMode: prefs.permissionMode, - fastMode: draftState.getFastModeForModel(prefs.model), - planModeEnabled: false, - }, - }); - if (!isDataOwnerGenerationCurrent(owner)) return; - if (result?.sessionId) navigate(`/cc-agent/${result.sessionId}`); - else toast.error(t('cindyMake.merge.errors.startFailed')); - } catch { - if (isDataOwnerGenerationCurrent(owner)) toast.error(t('cindyMake.merge.errors.startFailed')); - } finally { - submitting.current = false; - setBusy(false); - } - }; + const { resolveMerge, busy } = useCindyMakeMergeResolution(); + const cancelling = state.cancellationRequested || state.error === 'cancelFailed'; return ( - - ); } -export function CindyMakeMergeNotice({ state }: { state: CindyMakeMergeState }) { +export function CindyMakeMergeNotice({ + state, + busy, +}: { + state: CindyMakeMergeState; + busy?: boolean; +}) { const { t } = useTranslation(); const active = ['fetching', 'merging', 'checking'].includes(state.status); return ( @@ -78,9 +61,10 @@ export function CindyMakeMergeNotice({ state }: { state: CindyMakeMergeState }) {state.ownedByAnotherAccount &&

{t('cindyMake.merge.otherAccount')}

} {!active && - state.hasWorkspace && + (state.hasWorkspace || state.cancellationRequested || state.error === 'cancelFailed') && state.status !== 'merged' && - !state.ownedByAnotherAccount && } + state.status !== 'cancelled' && + !state.ownedByAnotherAccount && } ); } diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakePreflightDialog.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakePreflightDialog.tsx index c5fa887524..1604be21fc 100644 --- a/apps/desktop/src/renderer/components/cindy-make/CindyMakePreflightDialog.tsx +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakePreflightDialog.tsx @@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from 'react'; import * as Dialog from '@radix-ui/react-dialog'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { X } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { Tip } from '@/components/ui/tooltip'; import { MakeDoctorReportCard } from '@/components/chat/CindyMakeDoctorCard'; import { getDataOwnerGeneration, @@ -33,8 +36,10 @@ export function CindyMakePreflightDialog({ const [attempt, setAttempt] = useState(0); const [starting, setStarting] = useState(false); const [failed, setFailed] = useState(false); + const [confirmClose, setConfirmClose] = useState(false); const submitting = useRef(false); const contentRef = useRef(null); + const closeButtonRef = useRef(null); const mounted = useRef(false); const owner = useRef(getDataOwnerGeneration()); const returnFocus = useRef( @@ -80,6 +85,7 @@ export function CindyMakePreflightDialog({ if ( !current() || submitting.current || + confirmClose || report?.status !== 'completed' || !['found', 'notFound'].includes(report.upstream?.status ?? '') ) @@ -122,17 +128,35 @@ export function CindyMakePreflightDialog({ } }; + const requestClose = () => { + if (!submitting.current) setConfirmClose(true); + }; + const closeLabel = t(starting ? 'cindyMake.preflight.creating' : 'common.dismiss'); + // Read the live report while confirmation is open: preparation may finish + // before the user decides, changing what closing will actually stop. + const closeStage = + !report || report.status === 'running' + ? report?.upstream?.status === 'searching' + ? 'upstream' + : report?.source?.status === 'preparing' + ? 'source' + : 'environment' + : report.status === 'completed' && + ['found', 'notFound'].includes(report.upstream?.status ?? '') + ? 'ready' + : 'incomplete'; + return ( - !submitting.current && onOpenChange(open)}> + !open && requestClose()}> { // The report and Continue action arrive asynchronously. Start at the - // readable content instead of making Cancel the initial action. + // readable content instead of making Close the initial action. event.preventDefault(); contentRef.current?.focus({ preventScroll: true }); }} @@ -145,56 +169,85 @@ export function CindyMakePreflightDialog({ event.preventDefault(); }} > - - {t('cindyMake.title')} - - - {request} - - {report && ( - { - if (!submitting.current && current()) { - setFailed(false); - setAttempt((value) => value + 1); - } - }} - onStop={() => { - if (current()) - void cancelMakeDoctor(report.runId, report.mode).catch(() => - toast.error(t('cindyMakeDoctor.failed')), - ); - }} - /> - )} - {failed && ( -

- {t('cindyMake.code.preparationFailed')} -

- )} -
- - - - {report?.status === 'completed' && - ['found', 'notFound'].includes(report.upstream?.status ?? '') && ( +
+ + {t('cindyMake.title')} + + + + + + +
+
+ + {request} + + {report && ( + { + if (!submitting.current && !confirmClose && current()) { + setFailed(false); + setAttempt((value) => value + 1); + } + }} + onStop={() => { + if (!confirmClose && current()) + void cancelMakeDoctor(report.runId, report.mode).catch(() => + toast.error(t('cindyMakeDoctor.failed')), + ); + }} + /> + )} + {failed && ( +

+ {t('cindyMake.code.preparationFailed')} +

+ )} +
+ {report?.status === 'completed' && + ['found', 'notFound'].includes(report.upstream?.status ?? '') && ( +
- )} -
+
+ )} + { + setConfirmClose(open); + if (!open) + requestAnimationFrame(() => closeButtonRef.current?.focus({ preventScroll: true })); + }} + title={t('cindyMake.preflight.closeTitle')} + description={t(`cindyMake.preflight.closeDescription.${closeStage}`)} + confirmText={t('cindyMake.preflight.closeConfirm')} + cancelText={t('cindyMake.preflight.keepOpen')} + zIndex={10002} + onConfirm={() => { + if (!submitting.current) onOpenChange(false); + }} + />
diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakeSourceDetails.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakeSourceDetails.tsx index 88d23b3e4f..0a5ee80d5e 100644 --- a/apps/desktop/src/renderer/components/cindy-make/CindyMakeSourceDetails.tsx +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakeSourceDetails.tsx @@ -8,86 +8,120 @@ import type { export function CindyMakeSourceDetails({ source, latestVersion, + showComparison = true, }: { source: MakeSourcePreparation; latestVersion?: MakeSourceLatestVersion; + /** Progress and errors may occupy the status area while version facts remain visible. */ + showComparison?: boolean; }) { const { t } = useTranslation(); const unknown = t('cindyMake.source.details.unknown'); const { personalAhead: ahead, personalBehind: behind } = source; const comparison = - ahead === undefined || behind === undefined + !source.commit || !source.mainCommit ? 'unknown' - : ahead === 0 && behind === 0 + : source.commit === source.mainCommit ? 'same' - : behind === 0 - ? 'personalAhead' - : ahead === 0 - ? 'mainAhead' - : 'diverged'; + : ahead === undefined || behind === undefined || (ahead === 0 && behind === 0) + ? 'different' + : behind === 0 + ? 'personalAhead' + : ahead === 0 + ? 'mainAhead' + : 'diverged'; + const latestChannel = latestVersion?.channel ?? source.channel; + const latestLabel = latestChannel ?? (source.ref === 'main' ? 'dev' : 'unknown'); + const onlineLabel = t('cindyMake.source.details.latest.' + latestLabel); + const mainMatchesOnline = + latestVersion?.status === 'ready' && source.mainCommit === latestVersion.commit; let upstreamDifference: string | undefined; - if ( + if (mainMatchesOnline) { + upstreamDifference = t('cindyMake.source.details.latest.same', { target: onlineLabel }); + } else if ( latestVersion?.status === 'ready' && + source.mainCommit && latestVersion.ahead !== undefined && - latestVersion.behind !== undefined + latestVersion.behind !== undefined && + (latestVersion.ahead !== 0 || latestVersion.behind !== 0) ) { const { ahead, behind } = latestVersion; upstreamDifference = - ahead === 0 && behind === 0 - ? t('cindyMake.source.details.latest.same') - : ahead === 0 - ? t('cindyMake.source.details.latest.behind', { count: behind }) - : behind === 0 - ? t('cindyMake.source.details.latest.ahead', { count: ahead }) - : t('cindyMake.source.details.latest.difference', { ahead, behind }); + ahead === 0 + ? t('cindyMake.source.details.latest.behind', { count: behind }) + : behind === 0 + ? t('cindyMake.source.details.latest.ahead', { count: ahead }) + : t('cindyMake.source.details.latest.difference', { ahead, behind }); } return ( -
-
-
-
{t('cindyMake.versions.personal')}
-
- {source.commit?.slice(0, 12) ?? unknown} -
-
-
- -
-
{t('cindyMake.overview.localMain')}
-
- {source.mainCommit?.slice(0, 12) ?? unknown} -
-
-
-
-

- {t('cindyMake.overview.comparison.' + comparison, { ahead, behind })} -

- {latestVersion && ( -
-
{t('cindyMake.source.details.latest.' + latestVersion.channel)}
-
- {latestVersion.status === 'ready' ? ( - <> - +
+
+ {t('cindyMake.overview.localMain')} +
+
+ + {source.mainCommit?.slice(0, 12) ?? unknown} + + {!mainMatchesOnline && ( + + + + {onlineLabel} + {latestVersion?.status === 'ready' ? ( + {latestVersion.commit.slice(0, 12)} - {latestVersion.ref !== 'main' && {latestVersion.ref}} - - {t('cindyMake.overview.localMain')}{' '} - {upstreamDifference ?? t('cindyMake.overview.comparisonUnavailable')} + ) : ( + + {latestVersion?.status === 'unavailable' + ? t('cindyMake.overview.lookupUnavailable') + : unknown} - - ) : ( - t('cindyMake.overview.lookupUnavailable') + )} + + + )} + {latestVersion?.status === 'ready' && ( + <> + {latestVersion.ref !== 'main' && ( + {latestVersion.ref} )} -
-
- )} -
+ + {upstreamDifference ?? t('cindyMake.overview.comparisonUnavailable')} + + + )} + +
{t('cindyMake.overview.personal')}
+
+ + {source.commit?.slice(0, 12) ?? unknown} + + {showComparison && ( + + {t('cindyMake.overview.comparison.' + comparison, { ahead, behind })} + + )} +
+ ); } diff --git a/apps/desktop/src/renderer/components/cindy-make/CindyMakeTestCard.tsx b/apps/desktop/src/renderer/components/cindy-make/CindyMakeTestCard.tsx index 9f0961b7fb..695346b717 100644 --- a/apps/desktop/src/renderer/components/cindy-make/CindyMakeTestCard.tsx +++ b/apps/desktop/src/renderer/components/cindy-make/CindyMakeTestCard.tsx @@ -16,6 +16,7 @@ import type { CindyMakePersonalBuildState, } from '../../../shared/cindyMakeSession'; import { CindyMakeCompleteCard } from './CindyMakeCompleteCard'; +import { CindyMakeBuildLog } from './CindyMakeBuildLog'; import { CindyMakeTestStep } from './CindyMakeTestStep'; type CompletedTestProps = { @@ -225,10 +226,16 @@ function CindyMakeCompletedTest({ sessionId, completionId, meta, onContinue }: C } } catch (error) { if (current()) { - const code = extractIpcError(error)?.message; + const code = extractIpcError(error)?.message.replace(/^\[PRECONDITION_FAILED\]\s*/, ''); setError({ - mode: action === 'build' || action === 'open-build' ? 'build' : 'test', - code: code === 'changed' || code === 'environment' ? code : 'unavailable', + mode: + code !== 'stopFailed' && (action === 'build' || action === 'open-build') + ? 'build' + : 'test', + code: + code === 'changed' || code === 'environment' || code === 'stopFailed' + ? code + : 'unavailable', }); } } finally { @@ -248,8 +255,6 @@ function CindyMakeCompletedTest({ sessionId, completionId, meta, onContinue }: C pending === 'build' || pending === 'open-build' || (error?.mode ?? meta.lastAction ?? (meta.personal ? 'build' : 'test')) === 'build'); - const retryTest = - !starting && (testStatus === 'failed' || testStatus === 'stopped' || error?.mode === 'test'); const stopping = building && (stoppingBuild || personal?.stopping === true); const stopBuild = async () => { if (!personal?.buildId || stopping || !current()) return; @@ -283,9 +288,11 @@ function CindyMakeCompletedTest({ sessionId, completionId, meta, onContinue }: C buildMode && buildStatus ? stopping ? 'cindyMake.history.stopping' - : buildStatus === 'checking' && personal?.checkStep - ? 'cindyMake.personal.checkStep.' + personal.checkStep - : 'cindyMake.personal.status.' + buildStatus + : buildStatus === 'waiting' && personal?.preparationStep + ? 'cindyMake.personal.preparationStep.' + personal.preparationStep + : buildStatus === 'checking' && personal?.checkStep + ? 'cindyMake.personal.checkStep.' + personal.checkStep + : 'cindyMake.personal.status.' + buildStatus : 'cindyMake.test.status.' + testStatus, )} description={t( @@ -303,11 +310,13 @@ function CindyMakeCompletedTest({ sessionId, completionId, meta, onContinue }: C { name: personal?.artifactName ?? '' }, )} detail={ - !buildMode ? ( + buildMode ? ( + + ) : ( - ) : undefined + ) } > {versions.error && ( @@ -347,13 +356,7 @@ function CindyMakeCompletedTest({ sessionId, completionId, meta, onContinue }: C loading={starting} onClick={() => void act('start')} > - {t( - testStatus === 'ready' - ? 'cindyMake.test.started' - : retryTest - ? 'cindyMake.test.retry' - : 'cindyMake.test.start', - )} + {t(testStatus === 'ready' ? 'cindyMake.test.started' : 'cindyMake.test.start')} {status && (status.branch || status.commit || status.status === 'ready') && - !preparing && - !updating && - !displayMerge && ( - + !preparing && ( + )} {status?.status === 'ready' && onPrepare && ( @@ -513,7 +536,7 @@ function CindyMakeSourceStatusCard({ )} {displayMerge ? ( - + ) : status && (preparing || updating || status.status !== 'ready') ? (
diff --git a/apps/desktop/src/renderer/components/settings/__tests__/CindyMakeSection.test.tsx b/apps/desktop/src/renderer/components/settings/__tests__/CindyMakeSection.test.tsx index 5d73090519..1d6144eba0 100644 --- a/apps/desktop/src/renderer/components/settings/__tests__/CindyMakeSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/__tests__/CindyMakeSection.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { isValidElement } from 'react'; import { CindyMakeSection } from '../CindyMakeSection'; import type { CindyMakeMergeState, CindyMakeMergeRequest } from '../../../../shared/cindyMakeMerge'; @@ -32,7 +32,9 @@ vi.mock('@/state/newMakerDraft', () => ({ }), getFastModeForModel: () => false, })); -const confirmMerge = vi.hoisted(() => vi.fn(async () => true)); +const confirmMerge = vi.hoisted(() => + vi.fn(async (_options?: unknown, _signal?: AbortSignal) => true), +); vi.mock('@/components/ui/confirm-dialog-provider', () => ({ useConfirmDialog: () => ({ confirm: confirmMerge, @@ -175,6 +177,7 @@ function renderEnvironment(ui: Parameters[0]) { } beforeEach(() => { + confirmMerge.mockReset().mockResolvedValue(true); vi.mocked(toast.success).mockClear(); vi.mocked(toast.error).mockClear(); setDataOwnerGeneration('settings-make-test-owner'); @@ -223,10 +226,11 @@ describe('Settings > Cindy Make', () => { expect(screen.queryByRole('switch')).toBeNull(); expect(await screen.findByText('0.1.99')).toBeTruthy(); expect( - within( - screen.getByRole('tabpanel', { name: 'settings.cindyMake.tabs.versions' }), - ).queryByRole('button', { name: 'settings.cindyMake.create.title' }), - ).toBeNull(); + within(screen.getByRole('tabpanel', { name: 'settings.cindyMake.tabs.versions' })).getByRole( + 'button', + { name: 'settings.cindyMake.create.title' }, + ), + ).toBeTruthy(); const source = screen.getByRole('region', { name: 'settings.cindyMake.source.title' }); expect( source.parentElement?.contains( @@ -362,7 +366,21 @@ describe('Settings > Cindy Make', () => { 'shows live progress, then a quiet success or actionable %s result on reentry', async (result) => { const h = harness(); - const source: MakeSourceStatus = { status: 'ready', path: '/managed/source', ref: 'main' }; + const source: MakeSourceStatus = { + status: 'ready', + path: '/managed/source', + ref: 'main', + commit: 'b'.repeat(40), + mainCommit: 'c'.repeat(40), + latestVersion: { + status: 'ready', + channel: 'dev', + ref: 'main', + commit: 'a'.repeat(40), + ahead: 0, + behind: 6, + }, + }; const previous: CindyMakeMergeState = { id: 'previous-update', status: 'merged', @@ -380,13 +398,23 @@ describe('Settings > Cindy Make', () => { }), ); const card = screen.getByRole('region', { name: 'settings.cindyMake.source.title' }); + const expectVersionsVisible = (region: HTMLElement) => { + for (const commit of ['a', 'b', 'c']) { + expect(within(region).getByText(commit.repeat(12)).title).toBe(commit.repeat(40)); + } + expect(within(region).getByText('cindyMake.source.details.latest.dev')).toBeTruthy(); + expect(within(region).getByText(/cindyMake.source.details.latest.behind/)).toBeTruthy(); + }; + expectVersionsVisible(card); fireEvent.click(within(card).getByRole('button', { name: 'cindyMake.merge.getLatest' })); await waitFor(() => expect(h.api.cindyMakeMerge).toHaveBeenCalledOnce()); + expectVersionsVisible(card); expect(within(card).getByRole('status').textContent).toBe('cindyMake.merge.status.fetching'); expect(within(card).queryByText('cindyMake.merge.status.merged')).toBeNull(); const current = { ...previous, id: 'current-update' }; for (const status of ['fetching', 'merging', 'checking'] as const) { await h.pushState({ source, upstreamMerge: { ...current, status } }); + expectVersionsVisible(card); expect(within(card).getAllByRole('status')).toHaveLength(1); expect(within(card).getByRole('status').textContent).toBe( 'cindyMake.merge.status.' + status, @@ -422,11 +450,12 @@ describe('Settings > Cindy Make', () => { expect( within(reopened).getByText( result === 'merged' - ? 'cindyMake.overview.comparison.unknown' + ? 'cindyMake.overview.comparison.different' : 'cindyMake.merge.status.failed', ), ).toBeTruthy(), ); + expectVersionsVisible(reopened); expect(within(reopened).getAllByRole('status')).toHaveLength(1); expect(within(reopened).queryByText('settings.cindyMake.source.status.ready')).toBeNull(); if (result === 'failed') @@ -470,7 +499,208 @@ describe('Settings > Cindy Make', () => { expect(within(card).getByText('a'.repeat(12))).toBeTruthy(); expect(within(card).getByText(/cindyMake.overview.lookupUnavailable/)).toBeTruthy(); }); - it('offers conflict resolution only after a conflict and creates a task only on click', async () => { + it('keeps version details when opening Settings during an update and refreshes them on completion', async () => { + const h = harness(); + const source: MakeSourceStatus = { + status: 'ready', + path: '/managed/source', + ref: 'main', + commit: 'b'.repeat(40), + mainCommit: 'c'.repeat(40), + personalAhead: 0, + personalBehind: 4, + }; + const merge: CindyMakeMergeState = { + id: 'merge', + status: 'fetching', + ref: 'main', + upstreamCommit: 'a'.repeat(40), + }; + await h.pushState({ source, upstreamMerge: merge }); + renderVersions(); + const card = screen.getByRole('region', { name: 'settings.cindyMake.source.title' }); + await waitFor(() => + expect(within(card).getByRole('status').textContent).toBe('cindyMake.merge.status.fetching'), + ); + expect(within(card).queryByText('cindyMake.merge.status.merged')).toBeNull(); + expect(within(card).queryByText('settings.cindyMake.source.status.ready')).toBeNull(); + + for (const status of ['fetching', 'merging', 'checking', 'conflict', 'resolving'] as const) { + await h.pushState({ + source, + upstreamMerge: { ...merge, status, hasWorkspace: true }, + }); + expect(within(card).getByText('b'.repeat(12))).toBeTruthy(); + expect(within(card).getByText('c'.repeat(12))).toBeTruthy(); + expect(within(card).getByRole('status').textContent).toBe(`cindyMake.merge.status.${status}`); + expect(within(card).queryByText('cindyMake.overview.comparison.mainAhead')).toBeNull(); + } + await h.pushState({ + source: { ...source, commit: 'd'.repeat(40), mainCommit: 'd'.repeat(40), personalBehind: 0 }, + upstreamMerge: { ...merge, status: 'merged' }, + }); + expect(within(card).getAllByText('d'.repeat(12))).toHaveLength(2); + expect(within(card).queryByText('b'.repeat(12))).toBeNull(); + expect(within(card).queryByText('c'.repeat(12))).toBeNull(); + expect(within(card).getByRole('status').textContent).toBe('cindyMake.overview.comparison.same'); + expect(within(card).queryByText('cindyMake.merge.status.merged')).toBeNull(); + }); + it.each([ + ['missing', undefined], + ['failed', 'installFailed'], + ['cancelled', 'cancelled'], + ] as const)( + 'keeps recovery for %s source available after an update failed without a workspace', + async (status, error) => { + const h = harness(); + const source: MakeSourceStatus = { status, error, path: '/managed/source', ref: 'main' }; + renderVersions(); + await h.pushState({ + source: { ...source, status: 'ready', error: undefined }, + upstreamMerge: { + id: 'previous-update', + status: 'failed', + ref: 'main', + upstreamCommit: 'a'.repeat(40), + error: 'gitFailed', + hasWorkspace: false, + }, + }); + const card = screen.getByRole('region', { name: 'settings.cindyMake.source.title' }); + expect(within(card).getByRole('status').textContent).toContain( + 'cindyMake.merge.errors.gitFailed', + ); + expect(within(card).queryByText('settings.cindyMake.source.status.ready')).toBeNull(); + + await h.pushSource(source); + expect(within(card).getByRole('status').textContent).toContain( + `settings.cindyMake.source.status.${status}`, + ); + expect(within(card).queryByText('cindyMake.merge.errors.gitFailed')).toBeNull(); + if (error) expect(within(card).getByText(`cindyMake.source.errors.${error}`)).toBeTruthy(); + fireEvent.click( + within(card).getByRole('button', { + name: + status === 'missing' + ? 'settings.cindyMake.source.prepare' + : 'settings.cindyMake.source.retry', + }), + ); + expect(within(card).getByRole('status').textContent).toBe( + 'settings.cindyMake.source.status.preparing', + ); + expect(h.starts().at(-1)?.[1].makeAction).toBe('prepare-source'); + await h.pushSource({ + status: 'preparing', + path: source.path, + phase: 'cloning', + progress: { stage: 'receiving', percent: 43 }, + }); + expect(within(card).getByRole('status').textContent).toContain('(43%)'); + fireEvent.click(within(card).getByRole('button', { name: 'settings.cindyMake.source.stop' })); + expect(h.api.cancelCindyMakeSource).toHaveBeenCalledTimes(1); + }, + ); + it.each([true, false])( + 'waits for the second confirmation after an update conflicts (confirm=%s)', + async (confirmed) => { + const h = harness(); + const source: MakeSourceStatus = { status: 'ready', path: '/managed/source', ref: 'main' }; + const conflict: CindyMakeMergeState = { + id: 'update-conflict', + status: 'conflict', + ref: 'main', + upstreamCommit: 'a'.repeat(40), + hasWorkspace: true, + }; + const result: CindyMakeMergeState = confirmed + ? { ...conflict, status: 'resolving', sessionId: 'merge-task' } + : { ...conflict, status: 'cancelled', hasWorkspace: false }; + let decide!: (confirmed: boolean) => void; + confirmMerge.mockResolvedValueOnce(true).mockImplementationOnce( + () => + new Promise((resolve) => { + decide = resolve; + }), + ); + h.api.cindyMakeMerge.mockResolvedValueOnce(conflict).mockResolvedValueOnce(result); + function Location() { + return {useLocation().pathname}; + } + renderVersions( + <> + + + , + ); + await h.pushSource(source); + const update = screen.getByRole('button', { name: 'cindyMake.merge.getLatest' }); + fireEvent.click(update); + await waitFor(() => expect(confirmMerge).toHaveBeenCalledTimes(2)); + expect(confirmMerge.mock.calls[1][0]).toMatchObject({ + title: 'cindyMake.merge.conflictConfirm.title', + description: 'cindyMake.merge.conflictConfirm.description', + confirmText: 'cindyMake.merge.conflictConfirm.confirm', + cancelText: 'cindyMake.merge.conflictConfirm.cancel', + }); + await h.pushState({ source, upstreamMerge: conflict }); + expect(h.api.cindyMakeMerge).toHaveBeenCalledTimes(1); + fireEvent.click(update); + fireEvent.click(screen.getByRole('button', { name: 'cindyMake.merge.resolve' })); + expect(confirmMerge).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('location').textContent).toBe('/'); + await act(async () => decide(confirmed)); + await waitFor(() => expect(h.api.cindyMakeMerge).toHaveBeenCalledTimes(2)); + expect(h.api.cindyMakeMerge.mock.calls[1][0]).toEqual( + confirmed + ? { + action: 'resolve', + operationId: conflict.id, + createOptions: expect.objectContaining({ agentKind: 'codex', model: 'test-model' }), + } + : { action: 'cancel', operationId: conflict.id }, + ); + await h.pushState({ source, upstreamMerge: result }); + expect(screen.getByTestId('location').textContent).toBe( + confirmed ? '/cc-agent/merge-task' : '/', + ); + if (!confirmed) { + expect(update.hasAttribute('disabled')).toBe(false); + expect(screen.queryByText('cindyMake.merge.status.conflict')).toBeNull(); + expect(screen.queryByRole('button', { name: 'cindyMake.merge.resolve' })).toBeNull(); + } + }, + ); + it.each(['unmount', 'account'] as const)( + 'does not apply a conflict decision after %s', + async (change) => { + const h = harness(); + const source: MakeSourceStatus = { status: 'ready', path: '/managed/source', ref: 'main' }; + h.api.cindyMakeMerge.mockResolvedValue({ + id: 'conflict', + status: 'conflict', + ref: 'main', + upstreamCommit: 'a'.repeat(40), + hasWorkspace: true, + }); + let decide!: (confirmed: boolean) => void; + confirmMerge.mockResolvedValueOnce(true).mockImplementationOnce( + () => + new Promise((resolve) => { + decide = resolve; + }), + ); + const view = renderVersions(); + await h.pushSource(source); + fireEvent.click(screen.getByRole('button', { name: 'cindyMake.merge.getLatest' })); + await waitFor(() => expect(confirmMerge).toHaveBeenCalledTimes(2)); + if (change === 'unmount') view.unmount(); + else await act(async () => setDataOwnerGeneration('another-owner')); + await act(async () => decide(false)); + expect(h.api.cindyMakeMerge).toHaveBeenCalledTimes(1); + }, + ); + it('offers conflict resolution only after a conflict and creates a task only on confirmation', async () => { const h = harness(); const source: MakeSourceStatus = { status: 'ready', path: '/managed/source', ref: 'main' }; const merge: CindyMakeMergeState = { @@ -486,11 +716,18 @@ describe('Settings > Cindy Make', () => { , ); await h.pushState({ source, upstreamMerge: merge }); - expect(screen.getByText('cindyMake.merge.status.conflict')).toBeTruthy(); + const card = screen.getByRole('region', { name: 'settings.cindyMake.source.title' }); + expect(within(card).getByRole('status').textContent).toBe('cindyMake.merge.status.conflict'); + expect(within(card).queryByText('settings.cindyMake.source.status.ready')).toBeNull(); expect(h.api.cindyMakeMerge).not.toHaveBeenCalled(); expect( screen.getByRole('button', { name: 'cindyMake.merge.getLatest' }).hasAttribute('disabled'), ).toBe(true); + await h.pushSource({ ...source, status: 'failed', error: 'dirty' }); + expect(within(card).getByRole('status').textContent).toBe('cindyMake.merge.status.conflict'); + expect( + within(card).queryByRole('button', { name: 'settings.cindyMake.source.retry' }), + ).toBeNull(); h.api.cindyMakeMerge.mockResolvedValue({ ...merge, status: 'resolving', @@ -503,6 +740,45 @@ describe('Settings > Cindy Make', () => { ), ); }); + it('retries cancellation without asking to resolve, even after the temporary directory is gone', async () => { + const h = harness(); + const source: MakeSourceStatus = { status: 'ready', path: '/managed/source', ref: 'main' }; + const merge: CindyMakeMergeState = { + id: 'merge', + status: 'failed', + error: 'cancelFailed', + cancellationRequested: true, + ref: 'main', + upstreamCommit: 'a'.repeat(40), + hasWorkspace: false, + }; + renderVersions(); + await h.pushState({ source, upstreamMerge: merge }); + const update = screen.getByRole('button', { name: 'cindyMake.merge.getLatest' }); + expect(update.hasAttribute('disabled')).toBe(true); + expect(screen.queryByRole('button', { name: 'cindyMake.merge.resolve' })).toBeNull(); + await h.pushSource({ ...source, status: 'failed', error: 'dirty' }); + const cancelled: CindyMakeMergeState = { + ...merge, + status: 'cancelled', + error: undefined, + cancellationRequested: undefined, + }; + h.api.cindyMakeMerge.mockResolvedValue(cancelled); + fireEvent.click(screen.getByRole('button', { name: 'cindyMake.merge.retryCancel' })); + await waitFor(() => + expect(h.api.cindyMakeMerge).toHaveBeenCalledExactlyOnceWith({ + action: 'cancel', + operationId: merge.id, + }), + ); + expect(confirmMerge).not.toHaveBeenCalled(); + await h.pushState({ source, upstreamMerge: cancelled }); + expect( + screen.getByRole('button', { name: 'cindyMake.merge.getLatest' }).hasAttribute('disabled'), + ).toBe(false); + expect(screen.queryByText('cindyMake.merge.errors.cancelFailed')).toBeNull(); + }); it.each(['resolving', 'failed'] as const)( 'keeps a %s upstream task outside the unfinished production list', async (status) => { @@ -761,7 +1037,7 @@ describe('Settings > Cindy Make', () => { expect(screen.queryByText('cindyMake.prepare.retry')).toBeNull(); }); - it('keeps the versions tab focused on existing versions without a creation entry', async () => { + it('offers creation from the version overview without starting preparation on open or cancel', async () => { const environment = harness(); renderEnvironment( @@ -771,8 +1047,14 @@ describe('Settings > Cindy Make', () => { await act(async () => { fireEvent.click(screen.getByRole('tab', { name: 'settings.cindyMake.tabs.versions' })); }); - expect(screen.queryByRole('button', { name: 'settings.cindyMake.create.title' })).toBeNull(); + const entry = screen.getByRole('button', { name: 'settings.cindyMake.create.title' }); + entry.focus(); + fireEvent.click(entry); + expect(screen.getByRole('dialog', { name: 'settings.cindyMake.create.title' })).toBeTruthy(); + expect(within(screen.getByRole('dialog')).getByRole('textbox')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'settings.cindyMake.create.cancel' })); expect(screen.queryByRole('dialog')).toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(entry)); expect(environment.starts()).toHaveLength(1); expect(environment.starts()[0][0]).toBe('cindy-make-doctor'); }); diff --git a/apps/desktop/src/renderer/features/cc-agent/SessionContentHeader.tsx b/apps/desktop/src/renderer/features/cc-agent/SessionContentHeader.tsx index 7d1bc298f1..a1aa4f5246 100644 --- a/apps/desktop/src/renderer/features/cc-agent/SessionContentHeader.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/SessionContentHeader.tsx @@ -171,7 +171,7 @@ export function SessionContentHeader({ // heartbeat schedule 绑定标识,与 SessionItem 同源数据;删除/过期后自动消失。 const boundSchedules = useSessionBoundSchedules(session.id); const displayTitle = - getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'))?.trim() || + getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'), t)?.trim() || t('ccAgent.sessionHeader.untitled'); const remoteIconKind = session.deviceLinkDeviceId ? 'device-link' diff --git a/apps/desktop/src/renderer/features/cc-agent/SplitGroup.tsx b/apps/desktop/src/renderer/features/cc-agent/SplitGroup.tsx index 3a908f83a2..28090c0314 100644 --- a/apps/desktop/src/renderer/features/cc-agent/SplitGroup.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/SplitGroup.tsx @@ -815,7 +815,7 @@ const SplitPaneView = memo(function SplitPaneView({ const isOwner = pane.key === ownerPaneKey; const viewSessionId = pane.sessionId; const session = sessionsById.get(viewSessionId) ?? null; - const title = session ? getSessionDisplayTitle(session, unnamedTitle) : loadingTitle; + const title = session ? getSessionDisplayTitle(session, unnamedTitle, t) : loadingTitle; return ( = {}): Session { return { @@ -29,6 +30,81 @@ function session(over: Partial = {}): Session { } describe('getSessionDisplayTitle', () => { + it.each([ + win32.join('C:/managed', 'merge-worktrees', MAKE_RUN), + posix.join('/managed', 'merge-worktrees', MAKE_RUN), + ])('recovers persisted conflict title keys without rewriting data for %s', (workingDir) => { + for (const [key, label] of [ + ['cindyMake.merge.taskTitle', '处理源码更新冲突'], + ['cindyMake.history.mergeTaskTitle', '处理合入冲突'], + ['cindyMake.history.revertTaskTitle', '处理撤销合入冲突'], + ]) { + for (const title of [key, `[f428] ${key}`, `[Cindy-Make] ${key}`]) { + const s = session({ + source: 'cindy-make-merge', + title, + workingDir, + createdAt: MAKE_CREATED_AT, + }); + const translate = vi.fn(() => label); + expect(getSessionDisplayTitle(s, UNNAMED, translate)).toBe(`[f428] 09-20 14:07 ${label}`); + expect(translate).toHaveBeenCalledWith(key); + expect(canHighlightSessionDisplayTitle(s)).toBe(false); + expect(s.title).toBe(title); + } + } + }); + + it('uses the current language for old keys and preserves renamed or ordinary titles', () => { + const key = 'cindyMake.merge.taskTitle'; + const s = session({ source: 'cindy-make-merge', title: key, createdAt: MAKE_CREATED_AT }); + expect(getSessionDisplayTitle(s, UNNAMED, () => '处理源码更新冲突')).toBe( + '09-20 14:07 处理源码更新冲突', + ); + expect(getSessionDisplayTitle(s, UNNAMED, () => 'Resolve Source Update Conflicts')).toBe( + '09-20 14:07 Resolve Source Update Conflicts', + ); + const translate = vi.fn(() => 'unexpected'); + for (const custom of [ + session({ title: key }), + session({ source: 'cindy-make', title: key }), + session({ source: 'cindy-make-merge', title: 'My merge notes' }), + session({ source: 'cindy-make-merge', title: `Investigate ${key}` }), + ]) { + translate.mockClear(); + expect(getSessionDisplayTitle(custom, UNNAMED, translate)).toBe(custom.title); + expect(canHighlightSessionDisplayTitle(custom, translate)).toBe(true); + if (custom.source === 'cindy-make-merge') { + expect(translate).not.toHaveBeenCalledWith(custom.title); + } else { + expect(translate).not.toHaveBeenCalled(); + } + } + }); + + it('keeps an existing title timestamp stable when displayed again or after the task changes', () => { + const title = '[f428] 09-19 09:00 处理合入冲突'; + const s = session({ + source: 'cindy-make-merge', + title, + workingDir: '/managed/merge-worktrees/' + MAKE_RUN, + createdAt: MAKE_CREATED_AT, + updatedAt: new Date(2026, 8, 21, 16, 30).toISOString(), + }); + const translate = (key: string) => key; + expect(getSessionDisplayTitle(s, UNNAMED, translate)).toBe(title); + expect(canHighlightSessionDisplayTitle(s, translate)).toBe(true); + }); + + it('does not invent a time for a retained task whose creation date is unavailable', () => { + const s = session({ + source: 'cindy-make-merge', + title: 'cindyMake.merge.taskTitle', + createdAt: '', + }); + expect(getSessionDisplayTitle(s, UNNAMED, () => '处理源码更新冲突')).toBe('处理源码更新冲突'); + }); + it.each([ win32.join('C:/managed', 'worktrees', MAKE_RUN), posix.join('/managed', 'worktrees', MAKE_RUN), diff --git a/apps/desktop/src/renderer/features/cc-agent/lib/sessionDisplayTitle.ts b/apps/desktop/src/renderer/features/cc-agent/lib/sessionDisplayTitle.ts index 0e357c2928..643d83c5a1 100644 --- a/apps/desktop/src/renderer/features/cc-agent/lib/sessionDisplayTitle.ts +++ b/apps/desktop/src/renderer/features/cc-agent/lib/sessionDisplayTitle.ts @@ -14,7 +14,12 @@ import { isDefaultDraftSessionTitle } from '@cindy/maker-shared/session-title'; import type { Session } from '@/lib/ccAgent.types'; import { cindyMakeWorktreeName, formatCindyMakeTitle } from '@/lib/cindyMakeTitle'; -import { isCindyMakeFamilySource } from '../../../../shared/cindyMakeMerge'; +import { + CINDY_MAKE_MERGE_SESSION_SOURCE, + isCindyMakeFamilySource, +} from '../../../../shared/cindyMakeMerge'; +import { formatCindyMakeMergeTitle } from '../../../../shared/cindyMakeMergeTitle'; +import { SUPPORTED_LOCALES } from '../../../../shared/locale'; import { getAutomationSessionDisplayTitle, @@ -22,6 +27,32 @@ import { SCHEDULE_TITLE_PREFIX, } from './scheduledSessionGrouping'; +const MAKE_MERGE_TITLE_KEYS = [ + 'cindyMake.merge.taskTitle', + 'cindyMake.history.mergeTaskTitle', + 'cindyMake.history.revertTaskTitle', +] as const; + +type TranslateSessionTitle = (key: string, options?: { lng: string }) => string; + +/** Recognize old default titles in their creation language without rewriting saved or custom names. */ +function getLegacyMakeTitleKey( + session: Session, + translate?: TranslateSessionTitle, +): string | undefined { + if (session.source !== CINDY_MAKE_MERGE_SESSION_SOURCE) return undefined; + const worktree = cindyMakeWorktreeName(session.workingDir); + const title = formatCindyMakeTitle(session.title, worktree); + return MAKE_MERGE_TITLE_KEYS.find( + (key) => + title === formatCindyMakeTitle(key, worktree) || + (translate && + SUPPORTED_LOCALES.some( + (lng) => title === formatCindyMakeTitle(translate(key, { lng }), worktree), + )), + ); +} + /** * 「空草稿会话」—— 标题仍是哨兵且一条消息都没有。 * @@ -38,6 +69,7 @@ export function isEmptyDraftSession(session: Session): boolean { * * `unnamedLabel` 传已解析的 i18n 文案(`ccAgent.common.unnamedSession`)——与 * `autoTitleFallbackLabels()` 同款:纯函数不碰 i18n 实例,好测也好复用。 + * `translate` 由当前界面传入,给旧版 Cindy Make 默认冲突标题补上翻译和创建时间。 * * 兜底条件**只看标题是不是哨兵、不看消息数**,比 {@link isEmptyDraftSession} 更宽: * 自动起名失败(离线 / 模型不可用)或纯附件首条消息连描述都合成不出来时,会话有消息 @@ -51,10 +83,18 @@ export function isEmptyDraftSession(session: Session): boolean { * 英文占位、且在意它逐字显示」这一种情形;而放宽条件换掉的是自动起名失败时英文哨兵 * 直接漏给用户看 —— 那是本 PR 存在的理由。故按现状取舍(PR #1031 review,第 11 轮)。 */ -export function getSessionDisplayTitle(session: Session, unnamedLabel: string): string { +export function getSessionDisplayTitle( + session: Session, + unnamedLabel: string, + translate?: TranslateSessionTitle, +): string { if (isDefaultDraftSessionTitle(session.title)) return unnamedLabel; if (isCindyMakeFamilySource(session.source)) { - return formatCindyMakeTitle(session.title, cindyMakeWorktreeName(session.workingDir)); + const key = getLegacyMakeTitleKey(session, translate); + const title = key + ? formatCindyMakeMergeTitle(translate ? translate(key) : key, session.createdAt) + : session.title; + return formatCindyMakeTitle(title, cindyMakeWorktreeName(session.workingDir)); } return getAutomationSessionDisplayTitle(session); } @@ -96,11 +136,15 @@ export function toStoredSessionTitle(session: Session, editedTitle: string): str * - `[Schedule] xxx` 前缀被剥掉(既有 case); * - 哨兵标题被换成本地化的「未命名任务」(本次新增,同一个坑)。 * - Cindy Make 的旧前缀被替换为工作目录短标记。 + * - Cindy Make 默认冲突处理标题被补上翻译或创建时间。 */ -export function canHighlightSessionDisplayTitle(session: Session): boolean { +export function canHighlightSessionDisplayTitle( + session: Session, + translate?: TranslateSessionTitle, +): boolean { return ( !isScheduledSession(session) && !isDefaultDraftSessionTitle(session.title) && - getSessionDisplayTitle(session, '') === session.title + getSessionDisplayTitle(session, '', translate) === session.title ); } diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/RailNav.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/RailNav.tsx index dea1c527d7..33b8f2f762 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/RailNav.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/RailNav.tsx @@ -134,7 +134,7 @@ function SessionPreviewCard({ preview }: { preview: PreviewState }) { isRunning ? 'text-[var(--cmd-palette-item-meta)]' : 'text-foreground', )} > - {getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'))} + {getSessionDisplayTitle(session, t('ccAgent.common.unnamedSession'), t)}
{body && (
(function Sess ? 'cindyMake.code.taskName' : 'ccAgent.common.unnamedSession', ), + t, ); - const canHighlightDisplayTitle = canHighlightSessionDisplayTitle(session); + const canHighlightDisplayTitle = canHighlightSessionDisplayTitle(session, t); const isArchived = session.status === 'archived'; const canQuickArchive = !isArchived && !isEmpty && !remoteWritesBlocked; // 卡片/列表的正文固定给预览区域。list 保留实时执行文案,正文只用最近消息; diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/SessionItem.tsx b/apps/desktop/src/renderer/features/cc-agent/sidebar/SessionItem.tsx index 44a78aa301..2d0b92fe78 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/SessionItem.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/SessionItem.tsx @@ -509,8 +509,9 @@ export const SessionItem = withSidebarNavigation(function Sess ? 'cindyMake.code.taskName' : 'ccAgent.common.unnamedSession', ), + t, ); - const canHighlightDisplayTitle = canHighlightSessionDisplayTitle(session); + const canHighlightDisplayTitle = canHighlightSessionDisplayTitle(session, t); const titleContent = matchIndices && matchIndices.length > 0 && canHighlightDisplayTitle ? highlightSegments(session.title, matchIndices, { diff --git a/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/SessionCard.visual.test.ts b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/SessionCard.visual.test.ts index 6b48da1738..fd81e27c3c 100644 --- a/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/SessionCard.visual.test.ts +++ b/apps/desktop/src/renderer/features/cc-agent/sidebar/__tests__/SessionCard.visual.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { createElement, type ReactNode } from 'react'; +import { createInstance } from 'i18next'; import { act, cleanup, @@ -12,6 +13,11 @@ import { } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { cindyMakeState } from '@/lib/cindyMakeState'; +import en from '@/i18n/locales/en/common.json'; +import zhCN from '@/i18n/locales/zh-CN/common.json'; +import zhTW from '@/i18n/locales/zh-TW/common.json'; +import ja from '@/i18n/locales/ja/common.json'; +import ko from '@/i18n/locales/ko/common.json'; import { applyRemoteSessionActivity, @@ -31,6 +37,7 @@ const mocks = vi.hoisted(() => ({ pendingPluginSetupSessionIds: new Set(), attentionKindBySession: new Map(), ensureInitialMessages: vi.fn(), + translate: undefined as ((key: string, options?: Record) => string) | undefined, })); vi.mock('react-router-dom', () => ({ @@ -44,6 +51,7 @@ vi.mock('react-i18next', () => ({ }, useTranslation: () => ({ t: (key: string, options?: Record) => { + if (mocks.translate) return mocks.translate(key, options); const count = Number(options?.count ?? 0); const dict: Record = { 'ccAgent.time.relative.now': '刚刚', @@ -240,6 +248,7 @@ describe('SessionCard visual cases', () => { mocks.pendingPluginSetupSessionIds.clear(); mocks.attentionKindBySession.clear(); mocks.ensureInitialMessages.mockReset(); + mocks.translate = undefined; }); afterEach(() => { @@ -247,6 +256,70 @@ describe('SessionCard visual cases', () => { vi.restoreAllMocks(); }); + it.each([ + { locale: 'en', resource: en }, + { locale: 'zh-CN', resource: zhCN }, + { locale: 'zh-TW', resource: zhTW }, + { locale: 'ja', resource: ja }, + { locale: 'ko', resource: ko }, + ])( + 'renders retained conflict titles with local creation times in $locale', + async ({ locale, resource }) => { + const i18n = createInstance(); + await i18n.init({ + lng: locale, + fallbackLng: false, + defaultNS: 'common', + resources: { en: { common: en }, [locale]: { common: resource } }, + }); + mocks.translate = (key, options) => i18n.t(key, options); + const visualCase = sessionCardVisualCases.find((item) => item.id === 'short-idle-cc')!; + for (const variant of ['card', 'list'] as const) { + for (const [key, expected] of [ + ['cindyMake.merge.taskTitle', resource.cindyMake.merge.taskTitle], + ['cindyMake.history.mergeTaskTitle', resource.cindyMake.history.mergeTaskTitle], + ['cindyMake.history.revertTaskTitle', resource.cindyMake.history.revertTaskTitle], + ]) { + for (const title of [key, expected, `[fe0a] ${expected}`]) { + const view = renderCase(visualCase.id, { + variant, + session: { + ...visualCase.session, + source: 'cindy-make-merge', + title, + createdAt: new Date(2026, 8, 20, 14, 7).toISOString(), + workingDir: '/managed/merge-worktrees/fe0a1234', + }, + }); + expect( + screen.getByText(`[fe0a] 09-20 14:07 ${expected}`, { + selector: ':not([aria-hidden="true"])', + }), + ).toBeTruthy(); + expect(view.container.textContent).not.toMatch(/cindyMake[.]|[{][{]|[?]{2,}|�/); + view.unmount(); + } + } + } + // A task created in another UI language must still get its original creation time. + await i18n.changeLanguage('en'); + renderCase(visualCase.id, { + session: { + ...visualCase.session, + source: 'cindy-make-merge', + title: resource.cindyMake.merge.taskTitle, + createdAt: new Date(2026, 8, 20, 14, 7).toISOString(), + workingDir: '/managed/merge-worktrees/fe0a1234', + }, + }); + expect( + screen.getByText('[fe0a] 09-20 14:07 Resolve Source Update Conflicts', { + selector: ':not([aria-hidden="true"])', + }), + ).toBeTruthy(); + }, + ); + it.each(['card', 'list'] as const)( 'keeps %s active during preparation before the Agent starts', (variant) => { diff --git a/apps/desktop/src/renderer/features/cc-agent/workdir-browse/SessionTabsBar.tsx b/apps/desktop/src/renderer/features/cc-agent/workdir-browse/SessionTabsBar.tsx index 7b4c5dc498..1c00a2fe7a 100644 --- a/apps/desktop/src/renderer/features/cc-agent/workdir-browse/SessionTabsBar.tsx +++ b/apps/desktop/src/renderer/features/cc-agent/workdir-browse/SessionTabsBar.tsx @@ -131,12 +131,12 @@ export function SessionTabsBar({ // 显示标题也算「没改」(与 SessionContentHeader 同口径):未起名的会话 tab 上 // 预填的是本地化兜底文案,它不等于库里的英文哨兵 —— 只比原始 title 的话, // 用户双击后原样回车会把兜底文案写进库、冲掉哨兵,自动起名从此跳过这个会话。 - const displayed = target ? getSessionDisplayTitle(target, unnamedLabel) : ''; + const displayed = target ? getSessionDisplayTitle(target, unnamedLabel, t) : ''; if (!trimmed || trimmed === original || trimmed === displayed) return; // 预填是显示标题(legacy automation 会话已剥掉 `[Schedule] ` 前缀),落库前还原, // 否则会话会从 automation 分组里消失(PR #1031 review P1)。 onRename(id, target ? toStoredSessionTitle(target, trimmed) : trimmed); - }, [renamingId, editValue, sessions, onRename, unnamedLabel]); + }, [renamingId, editValue, sessions, onRename, unnamedLabel, t]); // 进编辑态后自动 focus + 全选 input。 useEffect(() => { @@ -248,7 +248,7 @@ export function SessionTabsBar({ > {sessions.map((session) => { const sessionId = session.id; - const title = getSessionDisplayTitle(session, unnamedLabel).trim() || unnamedLabel; + const title = getSessionDisplayTitle(session, unnamedLabel, t).trim() || unnamedLabel; const vendor = session.agentKind; const isActive = sessionId === activeSessionId; const isRunning = runningMap.has(sessionId); diff --git a/apps/desktop/src/renderer/hooks/__tests__/usePendingAlertAttention.test.ts b/apps/desktop/src/renderer/hooks/__tests__/usePendingAlertAttention.test.ts index 55e979bef3..c1cb084270 100644 --- a/apps/desktop/src/renderer/hooks/__tests__/usePendingAlertAttention.test.ts +++ b/apps/desktop/src/renderer/hooks/__tests__/usePendingAlertAttention.test.ts @@ -30,7 +30,7 @@ const kindMock = vi.mocked(getSessionAttentionKind); const errorTailPendingMock = vi.fn<() => Promise>(); const interruptedPendingMock = vi.fn<() => Promise>(); const createdListeners: Array< - (payload: { sessionId: string; message: { role?: string } }, ownerStamp?: unknown) => void + (payload: { sessionId: string; message: { role?: string; agentMeta?: unknown } }, ownerStamp?: unknown) => void > = []; /** 驱动一次错误尾行重算并等它收敛完成。 */ @@ -55,7 +55,7 @@ describe('usePendingAlertAttention (派生收敛)', () => { messages: { onErrorPersisted: () => () => {}, onCreated: ( - cb: (payload: { sessionId: string; message: { role?: string } }, ownerStamp?: unknown) => void, + cb: (payload: { sessionId: string; message: { role?: string; agentMeta?: unknown } }, ownerStamp?: unknown) => void, ) => { createdListeners.push(cb); return () => {}; @@ -261,6 +261,50 @@ describe('usePendingAlertAttention (派生收敛)', () => { expect(errorTailPendingMock).not.toHaveBeenCalled(); }); + it('Make 失败和重试的元数据更新会重算红点,同时保留未处理的中断', async () => { + interruptedPendingMock.mockResolvedValue(['interrupted']); + renderHook(() => usePendingAlertAttention()); + await vi.waitFor(() => expect(addMock).toHaveBeenCalledWith('interrupted', 'error')); + errorTailPendingMock.mockClear(); + errorTailPendingMock.mockResolvedValue(['make']); + + const push = (status: string) => createdListeners[0]!({ + sessionId: 'make', + message: { + role: 'assistant', + agentMeta: { cindyMakeCompletion: { + reportedAt: 100, lastAction: 'build', personal: { status }, + } }, + }, + }); + push('failed'); + await vi.waitFor(() => expect(addMock).toHaveBeenCalledWith('make', 'error')); + expect(errorTailPendingMock).toHaveBeenCalled(); + + clearMock.mockClear(); + addMock.mockClear(); + errorTailPendingMock.mockResolvedValue([]); + push('waiting'); + await vi.waitFor(() => expect(clearMock).toHaveBeenCalledWith('make', { intent: 'explicit' })); + expect(addMock).toHaveBeenCalledWith('interrupted', 'error'); + expect(clearMock).not.toHaveBeenCalledWith('interrupted', expect.anything()); + }); + + it('普通 Make 进度更新不反复查询告警', async () => { + renderHook(() => usePendingAlertAttention()); + await refreshPendingAlerts(); + errorTailPendingMock.mockClear(); + for (let i = 0; i < 10; i++) { + createdListeners[0]!({ sessionId: 'make', message: { + role: 'assistant', agentMeta: { cindyMakeCompletion: { + reportedAt: 100, lastAction: 'build', personal: { status: 'checking' }, + } }, + } }); + } + await Promise.resolve(); + expect(errorTailPendingMock).not.toHaveBeenCalled(); + }); + it('新一轮启动时重算仍认领的错误尾行,告警消失则 explicit 清点', async () => { await reconcile(['s1']); clearMock.mockClear(); diff --git a/apps/desktop/src/renderer/hooks/usePendingAlertAttention.ts b/apps/desktop/src/renderer/hooks/usePendingAlertAttention.ts index a547b52714..43eb596f45 100644 --- a/apps/desktop/src/renderer/hooks/usePendingAlertAttention.ts +++ b/apps/desktop/src/renderer/hooks/usePendingAlertAttention.ts @@ -19,7 +19,8 @@ * sessions:patched 里的 lastTurnEndedAt(用户点「继续 / 忽略」、其它窗口或 * device-link 控制端的 ack)。查询本身带 startedAt < bootAt 守卫,即使被运行时 * 调用也不会把正在跑的 turn 算进来。 - * - **错误尾行腿**(errorTailPending):参与每轮重算。它与 turn 是否在跑无关 —— + * - **错误尾行腿**(errorTailPending):参与每轮重算,也包含末尾 Make 卡片的原生失败。 + * 它与 turn 是否在跑无关 —— * turn 一跑起来就插入新的 user 行,error 行不再是尾行,自然不命中。 * * 两个账本因此独立:重算只差分错误尾行那本,不会顺手清掉中断点。 @@ -42,6 +43,7 @@ import { useEffect } from 'react'; import { isDataOwnerPushCurrent } from '@/contexts/dataOwnerGeneration'; +import { getCindyMakeMessageAttention } from '../../shared/cindyMakeAttention'; import { addSessionAttention, clearSessionAttention, @@ -267,13 +269,21 @@ export function usePendingAlertAttention(): void { // // user 行:只在本 hook 仍认领该会话的错误尾行时重算。文件头不变量是「新 turn // 的 user 行会让 error 不再是尾行」;自动续跑的 UI_ACTION_TRIGGER 也是 user 行, - // 若不订这条,横幅已灭、任务已在跑,红点却一直亮。其它会话 / 非 user 行不打 IPC。 + // 若不订这条,横幅已灭、任务已在跑,红点却一直亮。Make 直接更新同一张卡片, + // 没有新 error/user 行;失败、重试、继续的元数据广播也必须触发同一查询。 useEffect(() => { const onCreated = window.electronAPI?.localDb?.messages?.onCreated; if (!onCreated) return; return onCreated(({ sessionId, message }, ownerStamp) => { if (!isDataOwnerPushCurrent(ownerStamp)) return; - if (message?.role === 'error') { + const makeAttention = message && getCindyMakeMessageAttention(message); + // Preparation can push many progress frames. Recheck running state only when + // retiring a failure, including a failure query that has not returned yet. + if ( + message?.role === 'error' || + (makeAttention && + (makeAttention.kind !== 'running' || _errorTailOwned.has(sessionId) || _refreshInFlight)) + ) { void refreshPendingAlerts(); return; } diff --git a/apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts b/apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts index 24d9c69644..c67f92a960 100644 --- a/apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts +++ b/apps/desktop/src/renderer/hooks/useSessionRunningStatus.ts @@ -271,6 +271,7 @@ export function useSessionRunningStatus( !isRunning && !hasTerminalError && !stillPending && + getSessionAttentionKind(sessionId) !== 'error' && !ownedNow ) { addSessionAttention(sessionId, 'done'); diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index f9abbe9f74..a9fc163d9d 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -12780,6 +12780,19 @@ }, "cindyMake": { "beta": "Beta", + "preflight": { + "closeTitle": "Close Cindy Make?", + "closeConfirm": "Close Cindy Make", + "keepOpen": "Keep Viewing", + "creating": "Creating the build session, please wait…", + "closeDescription": { + "environment": "Environment preparation is still running. Closing will stop it, including the same preparation shown in Settings. Installed tools will be kept.", + "source": "Source preparation is still running. Closing will stop it, including the same preparation shown in Settings. The prepared environment and source will be kept.", + "upstream": "The upstream PR search is still running. Closing will stop the search. The prepared environment and source will be kept.", + "ready": "Preparation is complete, but work has not started. Closing will not create a build session. The prepared environment and source will be kept.", + "incomplete": "Preparation did not finish and work has not started. Closing will not create a build session. The prepared environment and source will be kept." + } + }, "description": "Check and prepare local tools for a personal Cindy build. Usage: /cindy-make [request]", "localOnly": "/cindy-make currently supports local desktop sessions only. Switch to this device to use it.", "usage": "Use /cindy-make or /cindy-make request. Environment checks do not yet accept attachments, references or comments. Your input is preserved.", @@ -12920,17 +12933,18 @@ }, "missing": "No source is prepared", "details": { - "unknown": "Update source to check", + "unknown": "Not read", "latest": { "dev": "Online main", "beta": "Latest Beta", "release": "Latest Release", - "same": "(up to date)", - "behind_one": "({{count}} commit behind)", - "behind_other": "({{count}} commits behind)", - "ahead_one": "({{count}} commit ahead)", - "ahead_other": "({{count}} commits ahead)", - "difference": "({{behind}} behind, {{ahead}} ahead)" + "same": "Matches {{target}}, 0 change records behind", + "behind_one": "Local main is {{count}} change record behind", + "behind_other": "Local main is {{count}} change records behind", + "ahead_one": "Local main is {{count}} change record ahead", + "ahead_other": "Local main is {{count}} change records ahead", + "difference": "Local main: {{behind}} change records behind, {{ahead}} change records ahead", + "unknown": "Online version" } } }, @@ -12987,15 +13001,17 @@ "switchVersion": "Switch version", "personalCode": "Personal source", "localMain": "Local main", - "lookupUnavailable": "Could not check the latest version. Sync latest source to check again.", - "comparisonUnavailable": "(comparison unavailable)", + "lookupUnavailable": "Lookup failed", + "comparisonUnavailable": "Local main comparison unavailable", "comparison": { - "same": "Personal source matches local main", - "personalAhead": "Includes local main · personal commits: {{ahead}}", - "mainAhead": "Commits ahead on local main: {{behind}}", - "diverged": "Commits ahead on local main: {{behind}} · personal commits: {{ahead}}", - "unknown": "Commit comparison unavailable. Sync latest source to try again." - } + "same": "Same SHA as local main", + "personalAhead": "Includes local main · personal change records: {{ahead}}", + "mainAhead": "Local main change records not yet included: {{behind}}", + "diverged": "Local main change records not yet included: {{behind}} · personal change records: {{ahead}}", + "different": "Different SHA from local main; difference unknown", + "unknown": "Difference from local main unknown" + }, + "personal": "cindy-personal" }, "versions": { "title": "My Versions", @@ -13028,6 +13044,10 @@ "open": "Show Installer", "description": "Merge these changes with your personal version, then save the new version after checks and packaging pass.", "readyHint": "Installer {{name}} is ready. Open its folder to install it.", + "preparationStep": { + "environment": "Preparing the build environment…", + "original": "Checking the current version…" + }, "checkStep": { "dependencies": "Installing build dependencies…", "tests": "Running automated tests; this may take several minutes…", @@ -13042,6 +13062,22 @@ "ready": "Personal Version Built", "failed": "Personal Version Could Not Be Built" }, + "buildLog": { + "title": "Build log", + "steps": { + "environment": "Prepare build environment", + "original": "Check current version", + "merging": "Merge changes", + "checking-dependencies": "Install build dependencies", + "checking-tests": "Run automated tests", + "checking-types": "Check code types", + "packaging": "Package personal version", + "publishing": "Save personal version", + "ready": "Personal version built", + "failed": "Build failed", + "cancelled": "Build stopped" + } + }, "errors": { "unavailable": "The build record or installer is unavailable. Build the personal version again.", "changed": "The task directory or code has changed. Finish editing before building again.", @@ -13052,7 +13088,7 @@ "baselineChanged": "The personal version changed during the build. Build again to include the latest changes.", "buildFailed": "Packaging did not finish. Retry or continue editing before building again.", "cancelled": "Generation cancelled. You can generate again.", - "cleanupFailed": "Generation stopped, but temporary files could not all be cleaned. Restart Cindy and try again.", + "cleanupFailed": "Automatic cleanup is incomplete. Select Generate personal version to try again.", "interrupted": "The build stopped. You can build the personal version again." } }, @@ -13083,9 +13119,9 @@ "environment": "The test environment is not ready. Check Cindy Make in Settings and retry.", "launchFailed": "The test build could not start. Retry or return to editing.", "timeout": "The test build took too long to start. Retry the launch.", - "interrupted": "The previous test was interrupted. Launch the isolated build again." + "interrupted": "The previous test was interrupted. Launch the isolated build again.", + "stopFailed": "The test version has not exited. Close its window, then try again." }, - "retry": "Retry Launch", "startingHint": "Editing is paused until startup finishes. Wait for the current step to complete.", "currentStep": "Current step: {{step}}", "failedStep": "Interrupted step: {{step}}", @@ -13124,6 +13160,8 @@ }, "counts": "{{total}} total · {{pending}} pending integration · {{integrated}} integrated", "filterLabel": "Filter make history", + "mergeTaskTitle": "Resolve Integration Conflicts", + "revertTaskTitle": "Resolve Undo Integration Conflicts", "filters": { "all": "All builds", "pending": "Pending integration", @@ -13181,7 +13219,6 @@ "resolve": "Resolve conflicts", "retry": "Retry", "build": "Generate personal version", - "retryBuild": "Retry generation", "end": "End build", "retry-prepare": "Retry preparation", "retry-cleanup": "Retry cleanup" @@ -13203,12 +13240,20 @@ }, "merge": { "getLatest": "Sync latest source", + "taskTitle": "Resolve Source Update Conflicts", "updateHint": "Update official source and reapply your personal changes.", "confirmTitle": "Update to the latest source?", - "confirmDescription": "Your personal changes will be kept. If conflicts occur, a separate task will help resolve them.", + "confirmDescription": "Your personal changes will be kept. If conflicts occur, you will be asked before a resolution session starts.", + "conflictConfirm": { + "title": "Source update has conflicts", + "description": "Confirm to create a separate session where the Agent will resolve the conflicts automatically. Cancel to abandon this integration and keep your current personal version.", + "confirm": "Confirm and resolve", + "cancel": "Cancel update" + }, "confirm": "Update source", "openTask": "Open resolution task", "resolve": "Resolve conflicts", + "retryCancel": "Retry cancellation", "resolveHint": "Open a separate task to resolve conflicts and check the result.", "otherAccount": "Another account started this operation. Switch back to that account to continue.", "status": { @@ -13218,7 +13263,8 @@ "resolving": "Resolving conflicts", "checking": "Checking changes…", "merged": "Source changes integrated", - "failed": "Source integration failed" + "failed": "Source integration failed", + "cancelled": "Source update cancelled" }, "errors": { "busy": "Another operation is running. Wait and retry.", @@ -13229,7 +13275,8 @@ "baselineChanged": "The personal source has changed. Retry using its latest state.", "checksFailed": "The changes did not pass checks. Open the resolution task to continue.", "interrupted": "The operation was interrupted. Your changes are kept; please retry.", - "startFailed": "Could not open the resolution task. Please retry." + "startFailed": "Could not open the resolution task. Please retry.", + "cancelFailed": "Could not cancel this update. Please retry. Your personal source has been kept." } } }, diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index fb8259cd66..693856699a 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -12763,6 +12763,19 @@ }, "cindyMake": { "beta": "Beta", + "preflight": { + "closeTitle": "Cindy Make を閉じますか?", + "closeConfirm": "Cindy Make を閉じる", + "keepOpen": "表示を続ける", + "creating": "制作セッションを作成中です。しばらくお待ちください…", + "closeDescription": { + "environment": "環境の準備が進行中です。閉じると準備が停止し、設定画面に表示されている同じ準備も停止します。インストール済みのツールは保持されます。", + "source": "ソースコードの準備が進行中です。閉じると準備が停止し、設定画面に表示されている同じ準備も停止します。準備済みの環境とソースコードは保持されます。", + "upstream": "上流 PR を検索中です。閉じると検索が停止します。準備済みの環境とソースコードは保持されます。", + "ready": "準備は完了していますが、制作はまだ始まっていません。閉じると制作セッションは作成されません。準備済みの環境とソースコードは保持されます。", + "incomplete": "準備は完了しておらず、制作もまだ始まっていません。閉じると制作セッションは作成されません。準備済みの環境とソースコードは保持されます。" + } + }, "description": "ローカルのツールを確認・準備して個人版 Cindy を作成します。使い方: /cindy-make [変更内容]", "localOnly": "/cindy-make は現在、ローカルのデスクトップセッションでのみ使用できます。このデバイスに切り替えてください。", "usage": "/cindy-make または /cindy-make 要望 を使用してください。環境チェックは添付ファイル、参照、コメントにまだ対応していません。入力内容は保持されます。", @@ -12903,17 +12916,18 @@ }, "missing": "ソースはまだ準備されていません", "details": { - "unknown": "ソースを更新して確認", + "unknown": "未取得", "latest": { "dev": "オンラインの main", "beta": "最新の beta", "release": "最新の正式版", - "same": "(同期済み)", - "behind_one": "({{count}} コミット遅れ)", - "behind_other": "({{count}} コミット遅れ)", - "ahead_one": "({{count}} コミット先行)", - "ahead_other": "({{count}} コミット先行)", - "difference": "({{behind}} コミット遅れ、{{ahead}} コミット先行)" + "same": "{{target}} と一致、変更履歴の遅れは 0 件", + "behind_one": "ローカル main は変更履歴が {{count}} 件遅れ", + "behind_other": "ローカル main は変更履歴が {{count}} 件遅れ", + "ahead_one": "ローカル main は変更履歴が {{count}} 件先行", + "ahead_other": "ローカル main は変更履歴が {{count}} 件先行", + "difference": "ローカル main は変更履歴が {{behind}} 件遅れ、{{ahead}} 件先行", + "unknown": "オンラインのバージョン" } } }, @@ -12970,15 +12984,17 @@ "switchVersion": "バージョンを切り替え", "personalCode": "個人版のソース", "localMain": "ローカル main", - "lookupUnavailable": "確認できませんでした。最新ソースを同期すると再確認します。", - "comparisonUnavailable": "(コミットを比較できません)", + "lookupUnavailable": "取得できませんでした", + "comparisonUnavailable": "ローカル main との差分は未取得", "comparison": { - "same": "個人版とローカル main のコミットは一致しています", - "personalAhead": "ローカル main を含み、個人版に {{ahead}} 件の追加コミットがあります", - "mainAhead": "ローカル main が {{behind}} コミット先行しています", - "diverged": "ローカル main が {{behind}} コミット先行し、個人版に {{ahead}} 件の追加コミットがあります", - "unknown": "コミットを比較できません。最新ソースを同期して再試行してください。" - } + "same": "ローカル main と同じ SHA", + "personalAhead": "ローカル main を含み、個人版の変更履歴が {{ahead}} 件追加", + "mainAhead": "ローカル main の変更履歴が {{behind}} 件未反映", + "diverged": "ローカル main の変更履歴が {{behind}} 件未反映、個人版の変更履歴が {{ahead}} 件追加", + "different": "ローカル main と SHA が異なり、差分は不明", + "unknown": "ローカル main との差分は不明" + }, + "personal": "cindy-personal" }, "versions": { "title": "マイバージョン", @@ -13011,6 +13027,10 @@ "open": "インストーラーの場所を開く", "description": "今回の変更を個人版にマージし、チェックとパッケージ化が完了してから新しい個人版として保存します。", "readyHint": "インストーラー {{name}} を作成しました。保存先フォルダーを開けます。", + "preparationStep": { + "environment": "ビルド環境を準備中…", + "original": "現在のバージョンを確認中…" + }, "checkStep": { "dependencies": "ビルドに必要な依存関係をインストール中…", "tests": "自動テストを実行中です。数分かかる場合があります…", @@ -13025,6 +13045,22 @@ "ready": "個人版を作成しました", "failed": "個人版を作成できませんでした" }, + "buildLog": { + "title": "ビルド記録", + "steps": { + "environment": "ビルド環境を準備", + "original": "現在のバージョンを確認", + "merging": "変更を統合", + "checking-dependencies": "ビルドの依存関係をインストール", + "checking-tests": "自動テストを実行", + "checking-types": "コードの型を確認", + "packaging": "個人版をパッケージ化", + "publishing": "個人版を保存", + "ready": "個人版を作成", + "failed": "ビルドに失敗", + "cancelled": "ビルドを停止" + } + }, "errors": { "unavailable": "ビルド記録またはインストーラーを読み込めません。再度ビルドしてください。", "changed": "作業ディレクトリまたはコードが変更されています。編集を完了してから再度ビルドしてください。", @@ -13035,7 +13071,7 @@ "baselineChanged": "ビルド中に個人版が更新されました。最新の変更を含めるため、再度ビルドしてください。", "buildFailed": "パッケージ化が完了しませんでした。再試行するか、編集を続けてください。", "cancelled": "制作をキャンセルしました。もう一度生成できます。", - "cleanupFailed": "制作を停止しましたが、一部の一時ファイルを削除できませんでした。Cindyを再起動して再試行してください。", + "cleanupFailed": "自動クリーンアップが完了していません。「個人版を生成」から再試行してください。", "interrupted": "ビルドを停止しました。個人版を再度ビルドできます。" } }, @@ -13066,9 +13102,9 @@ "environment": "テスト環境の準備ができていません。設定の Cindy Make で環境を確認し、再試行してください。", "launchFailed": "テスト版を起動できませんでした。再試行するか、編集に戻ってください。", "timeout": "テスト版の起動がタイムアウトしました。再試行してください。", - "interrupted": "前回のテストは中断されました。隔離テスト版を再起動してください。" + "interrupted": "前回のテストは中断されました。隔離テスト版を再起動してください。", + "stopFailed": "テスト版がまだ終了していません。テスト用ウィンドウを閉じてから、もう一度お試しください。" }, - "retry": "起動を再試行", "startingHint": "起動が完了するまで編集は一時停止されます。現在の手順が完了するまでお待ちください。", "currentStep": "現在の手順: {{step}}", "failedStep": "中断した手順: {{step}}", @@ -13107,6 +13143,8 @@ }, "counts": "合計 {{total}} 件 · 未取り込み {{pending}} 件 · 取り込み済み {{integrated}} 件", "filterLabel": "制作履歴を絞り込む", + "mergeTaskTitle": "取り込み時の競合を解決", + "revertTaskTitle": "取り込み取り消し時の競合を解決", "filters": { "all": "すべての制作", "pending": "未取り込み", @@ -13164,7 +13202,6 @@ "resolve": "競合を解決", "retry": "再試行", "build": "個人版を生成", - "retryBuild": "生成を再試行", "end": "制作を終了", "retry-prepare": "準備を再試行", "retry-cleanup": "クリーンアップを再試行" @@ -13184,12 +13221,20 @@ }, "merge": { "getLatest": "最新ソースを同期", + "taskTitle": "ソース更新時の競合を解決", "updateHint": "公式ソースを更新し、個人の変更を保持します。", "confirmTitle": "最新ソースに更新しますか?", - "confirmDescription": "個人の変更は保持されます。競合が発生した場合は、専用タスクで解決を支援します。", + "confirmDescription": "個人の変更は保持されます。競合が発生した場合は、解決用セッションを開始する前に確認します。", + "conflictConfirm": { + "title": "ソース更新で競合が発生しました", + "description": "確認すると独立したセッションを作成し、Agent が競合を自動で解決します。キャンセルすると今回の取り込みを中止し、現在の個人版を保持します。", + "confirm": "確認して解決", + "cancel": "更新をキャンセル" + }, "confirm": "ソースを更新", "openTask": "解決用タスクを開く", "resolve": "競合を解決", + "retryCancel": "キャンセルを再試行", "resolveHint": "専用タスクで競合を解決し、結果を確認します。", "otherAccount": "この操作は別のアカウントで開始されました。そのアカウントに切り替えて続行してください。", "status": { @@ -13199,7 +13244,8 @@ "resolving": "競合を解決中", "checking": "変更を確認中…", "merged": "ソースの変更を取り込みました", - "failed": "ソースの取り込みに失敗しました" + "failed": "ソースの取り込みに失敗しました", + "cancelled": "ソース更新をキャンセルしました" }, "errors": { "busy": "別の操作が実行中です。しばらくしてから再試行してください。", @@ -13210,7 +13256,8 @@ "baselineChanged": "個人ソースが変更されました。最新の状態で再試行してください。", "checksFailed": "変更がチェックを通過しませんでした。解決用タスクを開いて続行してください。", "interrupted": "操作が中断されました。変更は保持されています。再試行してください。", - "startFailed": "解決用タスクを開けませんでした。再試行してください。" + "startFailed": "解決用タスクを開けませんでした。再試行してください。", + "cancelFailed": "更新をキャンセルできませんでした。再試行してください。個人ソースは保持されています。" } } }, diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 80c62399cc..f07465d8ca 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -12763,6 +12763,19 @@ }, "cindyMake": { "beta": "Beta", + "preflight": { + "closeTitle": "Cindy Make를 닫을까요?", + "closeConfirm": "Cindy Make 닫기", + "keepOpen": "계속 보기", + "creating": "제작 세션을 만드는 중입니다. 잠시 기다려 주세요…", + "closeDescription": { + "environment": "환경을 준비하고 있습니다. 닫으면 준비가 중지되며 설정 화면에 표시된 동일한 준비도 중지됩니다. 설치된 도구는 유지됩니다.", + "source": "소스 코드를 준비하고 있습니다. 닫으면 준비가 중지되며 설정 화면에 표시된 동일한 준비도 중지됩니다. 준비된 환경과 소스 코드는 유지됩니다.", + "upstream": "업스트림 PR을 검색하고 있습니다. 닫으면 검색이 중지됩니다. 준비된 환경과 소스 코드는 유지됩니다.", + "ready": "준비가 완료되었지만 제작은 아직 시작되지 않았습니다. 닫으면 제작 세션이 생성되지 않습니다. 준비된 환경과 소스 코드는 유지됩니다.", + "incomplete": "준비가 완료되지 않았으며 제작도 아직 시작되지 않았습니다. 닫으면 제작 세션이 생성되지 않습니다. 준비된 환경과 소스 코드는 유지됩니다." + } + }, "description": "로컬 도구를 확인하고 준비하여 개인 버전 Cindy 제작을 시작합니다. 사용법: /cindy-make [변경 요청]", "localOnly": "/cindy-make는 현재 로컬 데스크톱 세션에서만 사용할 수 있습니다. 이 기기로 전환해 주세요.", "usage": "/cindy-make 또는 /cindy-make 요청 을 사용하세요. 환경 검사는 아직 첨부 파일, 참조, 댓글을 지원하지 않습니다. 입력 내용은 유지됩니다.", @@ -12903,17 +12916,18 @@ }, "missing": "아직 소스가 준비되지 않았습니다", "details": { - "unknown": "소스를 업데이트해 확인하세요", + "unknown": "미확인", "latest": { "dev": "온라인 main", "beta": "최신 beta", "release": "최신 정식 버전", - "same": "(동기화됨)", - "behind_one": "({{count}}개 커밋 뒤)", - "behind_other": "({{count}}개 커밋 뒤)", - "ahead_one": "({{count}}개 커밋 앞)", - "ahead_other": "({{count}}개 커밋 앞)", - "difference": "({{behind}}개 커밋 뒤, {{ahead}}개 커밋 앞)" + "same": "최신 상태 ({{target}}), 뒤처진 변경 기록 0개", + "behind_one": "로컬 main이 변경 기록 {{count}}개 뒤처짐", + "behind_other": "로컬 main이 변경 기록 {{count}}개 뒤처짐", + "ahead_one": "로컬 main이 변경 기록 {{count}}개 앞섬", + "ahead_other": "로컬 main이 변경 기록 {{count}}개 앞섬", + "difference": "로컬 main이 변경 기록 {{behind}}개 뒤처지고 {{ahead}}개 앞섬", + "unknown": "온라인 버전" } } }, @@ -12970,15 +12984,17 @@ "switchVersion": "버전 전환", "personalCode": "개인 버전 소스", "localMain": "로컬 main", - "lookupUnavailable": "확인하지 못했습니다. 최신 소스를 동기화할 때 다시 확인합니다.", - "comparisonUnavailable": "(커밋 비교 불가)", + "lookupUnavailable": "조회 실패", + "comparisonUnavailable": "로컬 main의 차이 미확인", "comparison": { - "same": "개인 버전과 로컬 main의 커밋이 같습니다", - "personalAhead": "로컬 main을 포함하며 개인 커밋이 {{ahead}}개 더 있습니다", - "mainAhead": "로컬 main이 {{behind}}개 커밋 앞서 있습니다", - "diverged": "로컬 main이 {{behind}}개 커밋 앞서 있으며 개인 커밋이 {{ahead}}개 더 있습니다", - "unknown": "커밋을 비교할 수 없습니다. 최신 소스를 동기화한 후 다시 시도하세요." - } + "same": "로컬 main과 SHA가 같음", + "personalAhead": "로컬 main을 포함하며 개인 변경 기록 {{ahead}}개 추가", + "mainAhead": "로컬 main의 변경 기록 {{behind}}개가 아직 반영되지 않음", + "diverged": "로컬 main의 변경 기록 {{behind}}개 미반영, 개인 변경 기록 {{ahead}}개 추가", + "different": "로컬 main과 SHA가 다름, 차이 알 수 없음", + "unknown": "로컬 main과의 차이 알 수 없음" + }, + "personal": "cindy-personal" }, "versions": { "title": "내 버전", @@ -13011,6 +13027,10 @@ "open": "설치 파일 위치 열기", "description": "이번 수정 사항을 개인 버전에 병합하고, 검사와 패키징을 통과한 후 새 개인 버전으로 저장합니다.", "readyHint": "설치 파일 {{name}}이 준비되었습니다. 저장된 폴더를 열 수 있습니다.", + "preparationStep": { + "environment": "빌드 환경 준비 중…", + "original": "현재 버전 확인 중…" + }, "checkStep": { "dependencies": "빌드 의존성 설치 중…", "tests": "자동 테스트 실행 중입니다. 몇 분 정도 걸릴 수 있습니다…", @@ -13025,6 +13045,22 @@ "ready": "개인 버전 생성 완료", "failed": "개인 버전을 생성하지 못했습니다" }, + "buildLog": { + "title": "빌드 기록", + "steps": { + "environment": "빌드 환경 준비", + "original": "현재 버전 확인", + "merging": "수정 사항 병합", + "checking-dependencies": "빌드 의존성 설치", + "checking-tests": "자동 테스트 실행", + "checking-types": "코드 타입 검사", + "packaging": "개인 버전 패키징", + "publishing": "개인 버전 저장", + "ready": "개인 버전 생성 완료", + "failed": "빌드 실패", + "cancelled": "빌드 중지" + } + }, "errors": { "unavailable": "빌드 기록이나 설치 파일을 읽을 수 없습니다. 개인 버전을 다시 빌드하세요.", "changed": "작업 디렉터리나 코드가 변경되었습니다. 수정을 완료한 후 다시 빌드하세요.", @@ -13035,7 +13071,7 @@ "baselineChanged": "빌드 중에 개인 버전이 변경되었습니다. 최신 수정 사항을 포함하려면 다시 빌드하세요.", "buildFailed": "패키징이 완료되지 않았습니다. 다시 시도하거나 수정을 계속하세요.", "cancelled": "제작이 취소되었습니다. 다시 생성할 수 있습니다.", - "cleanupFailed": "제작을 중지했지만 일부 임시 파일을 정리하지 못했습니다. Cindy를 다시 시작한 뒤 다시 시도하세요.", + "cleanupFailed": "자동 정리가 완료되지 않았습니다. 개인 버전 생성을 눌러 다시 시도하세요.", "interrupted": "빌드가 중지되었습니다. 개인 버전을 다시 빌드할 수 있습니다." } }, @@ -13066,9 +13102,9 @@ "environment": "테스트 환경이 준비되지 않았습니다. 설정의 Cindy Make에서 환경을 확인한 후 다시 시도하세요.", "launchFailed": "테스트 버전을 실행하지 못했습니다. 다시 시도하거나 편집으로 돌아가세요.", "timeout": "테스트 버전 실행 시간이 초과되었습니다. 다시 시도하세요.", - "interrupted": "이전 테스트가 중단되었습니다. 격리 테스트 버전을 다시 실행하세요." + "interrupted": "이전 테스트가 중단되었습니다. 격리 테스트 버전을 다시 실행하세요.", + "stopFailed": "테스트 버전이 아직 종료되지 않았습니다. 테스트 창을 닫은 후 다시 시도하세요." }, - "retry": "실행 다시 시도", "startingHint": "실행 준비가 끝날 때까지 수정이 일시 중지됩니다. 현재 단계가 완료될 때까지 기다려 주세요.", "currentStep": "현재 단계: {{step}}", "failedStep": "중단된 단계: {{step}}", @@ -13107,6 +13143,8 @@ }, "counts": "전체 {{total}}회 · 반영 대기 {{pending}}회 · 반영 완료 {{integrated}}회", "filterLabel": "제작 기록 필터", + "mergeTaskTitle": "반영 충돌 해결", + "revertTaskTitle": "반영 취소 충돌 해결", "filters": { "all": "전체 제작", "pending": "반영 대기", @@ -13164,7 +13202,6 @@ "resolve": "충돌 해결", "retry": "다시 시도", "build": "개인 버전 생성", - "retryBuild": "생성 다시 시도", "end": "제작 종료", "retry-prepare": "준비 다시 시도", "retry-cleanup": "정리 다시 시도" @@ -13184,12 +13221,20 @@ }, "merge": { "getLatest": "최신 소스 동기화", + "taskTitle": "소스 업데이트 충돌 해결", "updateHint": "공식 소스를 업데이트하고 개인 변경 사항을 유지합니다.", "confirmTitle": "최신 소스로 업데이트할까요?", - "confirmDescription": "개인 변경 사항은 유지됩니다. 충돌이 발생하면 별도 작업에서 해결을 도와드립니다.", + "confirmDescription": "개인 변경 사항은 유지됩니다. 충돌이 발생하면 해결 세션을 시작하기 전에 확인을 요청합니다.", + "conflictConfirm": { + "title": "소스 업데이트 중 충돌 발생", + "description": "확인하면 별도 세션을 만들고 Agent가 충돌을 자동으로 해결합니다. 취소하면 이번 반영을 중단하고 현재 개인 버전을 유지합니다.", + "confirm": "확인 및 해결", + "cancel": "업데이트 취소" + }, "confirm": "소스 업데이트", "openTask": "해결 작업 열기", "resolve": "충돌 해결", + "retryCancel": "취소 다시 시도", "resolveHint": "별도 작업에서 충돌을 해결하고 결과를 확인합니다.", "otherAccount": "다른 계정에서 시작한 작업입니다. 해당 계정으로 전환해 계속해 주세요.", "status": { @@ -13199,7 +13244,8 @@ "resolving": "충돌 해결 중", "checking": "변경 사항 확인 중…", "merged": "소스 변경 사항 반영 완료", - "failed": "소스 반영 실패" + "failed": "소스 반영 실패", + "cancelled": "소스 업데이트 취소됨" }, "errors": { "busy": "다른 작업이 진행 중입니다. 잠시 후 다시 시도해 주세요.", @@ -13210,7 +13256,8 @@ "baselineChanged": "개인 소스가 변경되었습니다. 최신 상태에서 다시 시도해 주세요.", "checksFailed": "변경 사항이 검사를 통과하지 못했습니다. 해결 작업을 열어 계속해 주세요.", "interrupted": "작업이 중단되었습니다. 변경 사항은 유지됩니다. 다시 시도해 주세요.", - "startFailed": "해결 작업을 열지 못했습니다. 다시 시도해 주세요." + "startFailed": "해결 작업을 열지 못했습니다. 다시 시도해 주세요.", + "cancelFailed": "업데이트를 취소하지 못했습니다. 다시 시도해 주세요. 개인 소스는 유지됩니다." } } }, diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 554c78b91c..f024a9861e 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -12763,6 +12763,19 @@ }, "cindyMake": { "beta": "Beta", + "preflight": { + "closeTitle": "关闭 Cindy Make?", + "closeConfirm": "关闭 Cindy Make", + "keepOpen": "继续查看", + "creating": "正在创建制作任务,请稍候…", + "closeDescription": { + "environment": "环境准备尚未完成。关闭将停止当前准备,设置页中的同一准备也会停止;已安装的工具会保留。", + "source": "源码准备尚未完成。关闭将停止当前准备,设置页中的同一准备也会停止;已准备好的环境和源码会保留。", + "upstream": "正在查询上游 PR。关闭将停止查询,已准备好的环境和源码会保留。", + "ready": "准备已完成,尚未开始制作。关闭后不会创建制作任务,已准备好的环境和源码会保留。", + "incomplete": "本次准备未完成,尚未开始制作。关闭后不会创建制作任务,已准备好的环境和源码会保留。" + } + }, "description": "检查并准备本机工具,开始制作个人版 Cindy。用法:/cindy-make [修改需求]", "localOnly": "/cindy-make 暂仅支持本机桌面任务。请切回本机后使用。", "usage": "请在 /cindy-make 后填写要修改的内容,例如 /cindy-make 修复消息流闪烁。空指令不会开始环境检查。环境检查暂不支持附件、引用或批注。", @@ -12903,17 +12916,18 @@ }, "missing": "尚未准备源码", "details": { - "unknown": "更新源码后查看", + "unknown": "未读取", "latest": { "dev": "线上 main", "beta": "最新 beta", "release": "最新正式版", - "same": "(已同步)", - "behind_one": "(落后 {{count}} 个提交)", - "behind_other": "(落后 {{count}} 个提交)", - "ahead_one": "(领先 {{count}} 个提交)", - "ahead_other": "(领先 {{count}} 个提交)", - "difference": "(落后 {{behind}},领先 {{ahead}} 个提交)" + "same": "与{{target}} 一致,落后 0 条修改记录", + "behind_one": "本地 main 落后 {{count}} 条修改记录", + "behind_other": "本地 main 落后 {{count}} 条修改记录", + "ahead_one": "本地 main 领先 {{count}} 条修改记录", + "ahead_other": "本地 main 领先 {{count}} 条修改记录", + "difference": "本地 main 落后 {{behind}} 条修改记录,领先 {{ahead}} 条修改记录", + "unknown": "线上版本" } } }, @@ -12970,15 +12984,17 @@ "switchVersion": "切换版本", "personalCode": "个人版代码", "localMain": "本地 main", - "lookupUnavailable": "暂未查到,同步最新源码时会重新检查。", - "comparisonUnavailable": "(暂时无法比较提交)", + "lookupUnavailable": "查询失败", + "comparisonUnavailable": "本地 main 的差距未读取", "comparison": { - "same": "个人版与本地 main 提交一致", - "personalAhead": "已包含本地 main,另有 {{ahead}} 个个人提交", - "mainAhead": "本地 main 领先 {{behind}} 个提交", - "diverged": "本地 main 领先 {{behind}} 个提交,个人版另有 {{ahead}} 个提交", - "unknown": "暂时无法比较提交,请同步最新源码重试。" - } + "same": "与本地 main 的 SHA 相同", + "personalAhead": "已包含本地 main,另有 {{ahead}} 条个人修改记录", + "mainAhead": "尚缺本地 main 的 {{behind}} 条修改记录", + "diverged": "尚缺本地 main 的 {{behind}} 条修改记录,另有 {{ahead}} 条个人修改记录", + "different": "SHA 与本地 main 不同,差距未知", + "unknown": "与本地 main 的差距未知" + }, + "personal": "cindy-personal" }, "versions": { "title": "我的版本", @@ -13011,6 +13027,10 @@ "open": "查看安装包", "description": "将本次修改与个人版合并,检查和打包通过后保存为新个人版。", "readyHint": "安装包 {{name}} 已生成,可打开所在目录。", + "preparationStep": { + "environment": "正在准备生成环境…", + "original": "正在检查当前版本…" + }, "checkStep": { "dependencies": "正在安装构建依赖…", "tests": "正在运行自动测试,可能需要数分钟…", @@ -13025,6 +13045,22 @@ "ready": "个人版已生成", "failed": "个人版未能生成" }, + "buildLog": { + "title": "生成记录", + "steps": { + "environment": "准备生成环境", + "original": "检查当前版本", + "merging": "合入修改", + "checking-dependencies": "安装构建依赖", + "checking-tests": "运行自动测试", + "checking-types": "检查代码类型", + "packaging": "打包个人版", + "publishing": "保存个人版", + "ready": "个人版已生成", + "failed": "生成失败", + "cancelled": "已停止生成" + } + }, "errors": { "unavailable": "无法读取生成记录或安装包,请重新生成。", "changed": "制作目录或代码已有变化,请完成修改后重新生成。", @@ -13035,7 +13071,7 @@ "baselineChanged": "生成期间个人版已有更新,请重新生成以包含最新修改。", "buildFailed": "打包未完成,请重试,或继续修改后再生成。", "cancelled": "制作已取消,可以重新生成个人版。", - "cleanupFailed": "制作已停止,但部分临时内容未能清理。请重启 Cindy 后再试。", + "cleanupFailed": "自动清理尚未完成,请再次点击“生成个人版”重试。", "interrupted": "生成已停止,可以重新生成个人版。" } }, @@ -13050,7 +13086,7 @@ "title": "修改已完成,选择下一步", "description": "可以继续修改、启动隔离版测试,或直接生成个人版安装包。", "readyHint": "请在测试窗口体验修改。点击“继续修改”会关闭测试版并恢复输入。", - "start": "启动隔离版测试", + "start": "启动隔离测试版", "started": "测试版已启动", "continue": "继续修改", "status": { @@ -13066,9 +13102,9 @@ "environment": "测试环境尚未就绪,请在设置中检查 Cindy Make 环境后重试。", "launchFailed": "未能启动隔离版,请重试;仍失败可返回继续修改。", "timeout": "隔离版启动超时,请重试。", - "interrupted": "上次测试已中断,请重新启动隔离版。" + "interrupted": "上次测试已中断,请重新启动隔离版。", + "stopFailed": "测试版尚未退出,请关闭测试窗口后重试。" }, - "retry": "重试启动", "startingHint": "启动完成前暂时不能继续修改,请等待当前步骤完成。", "currentStep": "当前步骤:{{step}}", "failedStep": "中断步骤:{{step}}", @@ -13107,6 +13143,8 @@ }, "counts": "共 {{total}} 次 · 待合入 {{pending}} 次 · 已合入 {{integrated}} 次", "filterLabel": "筛选制作历史", + "mergeTaskTitle": "处理合入冲突", + "revertTaskTitle": "处理撤销合入冲突", "filters": { "all": "全部制作", "pending": "待合入", @@ -13164,7 +13202,6 @@ "resolve": "处理冲突", "retry": "重试", "build": "生成个人版", - "retryBuild": "重新生成", "end": "结束制作", "retry-prepare": "重试准备", "retry-cleanup": "重试清理" @@ -13184,12 +13221,20 @@ }, "merge": { "getLatest": "同步最新源码", + "taskTitle": "处理源码更新冲突", "updateHint": "更新官方源码,并保留个人改动。", "confirmTitle": "更新到最新源码?", - "confirmDescription": "会保留你的个人改动。如有冲突,将通过独立任务协助处理。", + "confirmDescription": "会保留你的个人改动。如有冲突,会先询问是否创建任务处理。", + "conflictConfirm": { + "title": "源码更新遇到冲突", + "description": "确认后会创建独立任务,由 Agent 自动处理冲突。取消将放弃本次合入,保留现有个人版。", + "confirm": "确认并解决", + "cancel": "取消更新" + }, "confirm": "更新源码", "openTask": "打开处理任务", "resolve": "处理冲突", + "retryCancel": "重试取消", "resolveHint": "在独立任务中处理冲突并检查结果。", "otherAccount": "这项操作由其他账号发起,请切回该账号继续。", "status": { @@ -13199,7 +13244,8 @@ "resolving": "正在处理冲突", "checking": "正在检查改动…", "merged": "源码改动已合入", - "failed": "源码合入失败" + "failed": "源码合入失败", + "cancelled": "已取消源码更新" }, "errors": { "busy": "其他操作正在进行,请稍后重试。", @@ -13210,7 +13256,8 @@ "baselineChanged": "个人源码已发生变化,请基于最新状态重试。", "checksFailed": "改动未通过检查,请打开处理任务继续。", "interrupted": "操作已中断,改动已保留,请重试。", - "startFailed": "无法打开处理任务,请重试。" + "startFailed": "无法打开处理任务,请重试。", + "cancelFailed": "未能取消本次更新,请重试。个人版源码已保留。" } } }, diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 04979c989e..e1f00bbc28 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -12763,6 +12763,19 @@ }, "cindyMake": { "beta": "Beta", + "preflight": { + "closeTitle": "關閉 Cindy Make?", + "closeConfirm": "關閉 Cindy Make", + "keepOpen": "繼續查看", + "creating": "正在建立製作任務,請稍候…", + "closeDescription": { + "environment": "環境準備尚未完成。關閉將停止目前的準備,設定頁面中的同一項準備也會停止;已安裝的工具會保留。", + "source": "原始碼準備尚未完成。關閉將停止目前的準備,設定頁面中的同一項準備也會停止;已準備好的環境和原始碼會保留。", + "upstream": "正在查詢上游 PR。關閉將停止查詢,已準備好的環境和原始碼會保留。", + "ready": "準備已完成,尚未開始製作。關閉後不會建立製作任務,已準備好的環境和原始碼會保留。", + "incomplete": "本次準備未完成,尚未開始製作。關閉後不會建立製作任務,已準備好的環境和原始碼會保留。" + } + }, "description": "檢查並準備本機工具,開始製作個人版 Cindy。用法:/cindy-make [修改需求]", "localOnly": "/cindy-make 暫僅支援本機桌面任務。請切回本機後使用。", "usage": "請使用 /cindy-make 或 /cindy-make 需求。環境檢查暫不支援附件、引用或註解,輸入已保留。", @@ -12903,17 +12916,18 @@ }, "missing": "尚未準備原始碼", "details": { - "unknown": "更新原始碼後查看", + "unknown": "尚未讀取", "latest": { "dev": "線上 main", "beta": "最新 beta", "release": "最新正式版", - "same": "(已同步)", - "behind_one": "(落後 {{count}} 個提交)", - "behind_other": "(落後 {{count}} 個提交)", - "ahead_one": "(領先 {{count}} 個提交)", - "ahead_other": "(領先 {{count}} 個提交)", - "difference": "(落後 {{behind}},領先 {{ahead}} 個提交)" + "same": "與{{target}} 一致,落後 0 筆修改記錄", + "behind_one": "本機 main 落後 {{count}} 筆修改記錄", + "behind_other": "本機 main 落後 {{count}} 筆修改記錄", + "ahead_one": "本機 main 領先 {{count}} 筆修改記錄", + "ahead_other": "本機 main 領先 {{count}} 筆修改記錄", + "difference": "本機 main 落後 {{behind}} 筆修改記錄,領先 {{ahead}} 筆修改記錄", + "unknown": "線上版本" } } }, @@ -12970,15 +12984,17 @@ "switchVersion": "切換版本", "personalCode": "個人版程式碼", "localMain": "本機 main", - "lookupUnavailable": "暫時無法查詢,同步最新原始碼時會重新檢查。", - "comparisonUnavailable": "(暫時無法比較提交)", + "lookupUnavailable": "查詢失敗", + "comparisonUnavailable": "本機 main 的差距尚未讀取", "comparison": { - "same": "個人版與本機 main 提交一致", - "personalAhead": "已包含本機 main,另有 {{ahead}} 個個人提交", - "mainAhead": "本機 main 領先 {{behind}} 個提交", - "diverged": "本機 main 領先 {{behind}} 個提交,個人版另有 {{ahead}} 個提交", - "unknown": "暫時無法比較提交,請同步最新原始碼後重試。" - } + "same": "與本機 main 的 SHA 相同", + "personalAhead": "已包含本機 main,另有 {{ahead}} 筆個人修改記錄", + "mainAhead": "尚缺本機 main 的 {{behind}} 筆修改記錄", + "diverged": "尚缺本機 main 的 {{behind}} 筆修改記錄,另有 {{ahead}} 筆個人修改記錄", + "different": "SHA 與本機 main 不同,差距未知", + "unknown": "與本機 main 的差距未知" + }, + "personal": "cindy-personal" }, "versions": { "title": "我的版本", @@ -13011,6 +13027,10 @@ "open": "查看安裝套件", "description": "將本次修改與個人版合併,檢查和打包通過後儲存為新個人版。", "readyHint": "安裝套件 {{name}} 已產生,可開啟所在目錄。", + "preparationStep": { + "environment": "正在準備產生環境…", + "original": "正在檢查目前版本…" + }, "checkStep": { "dependencies": "正在安裝建置相依套件…", "tests": "正在執行自動測試,可能需要數分鐘…", @@ -13025,6 +13045,22 @@ "ready": "個人版已產生", "failed": "未能產生個人版" }, + "buildLog": { + "title": "產生記錄", + "steps": { + "environment": "準備產生環境", + "original": "檢查目前版本", + "merging": "合入修改", + "checking-dependencies": "安裝建置相依套件", + "checking-tests": "執行自動測試", + "checking-types": "檢查程式碼型別", + "packaging": "打包個人版", + "publishing": "儲存個人版", + "ready": "個人版已產生", + "failed": "產生失敗", + "cancelled": "已停止產生" + } + }, "errors": { "unavailable": "無法讀取產生記錄或安裝套件,請重新產生。", "changed": "製作目錄或程式碼已有變更,請完成修改後重新產生。", @@ -13035,7 +13071,7 @@ "baselineChanged": "產生期間個人版已有更新,請重新產生以包含最新修改。", "buildFailed": "打包未完成,請重試,或繼續修改後再產生。", "cancelled": "製作已取消,可以重新產生個人版。", - "cleanupFailed": "製作已停止,但部分暫存內容未能清理。請重新啟動 Cindy 後再試。", + "cleanupFailed": "自動清理尚未完成,請再次點擊「產生個人版」重試。", "interrupted": "產生程序已停止,可以重新產生個人版。" } }, @@ -13050,7 +13086,7 @@ "title": "修改已完成,選擇下一步", "description": "可以繼續修改、啟動隔離版測試,或直接產生個人版安裝套件。", "readyHint": "請在測試視窗體驗修改。點擊「繼續修改」會關閉測試版並恢復輸入。", - "start": "啟動隔離版測試", + "start": "啟動隔離測試版", "started": "測試版已啟動", "continue": "繼續修改", "status": { @@ -13066,9 +13102,9 @@ "environment": "測試環境尚未就緒,請在設定中檢查 Cindy Make 環境後重試。", "launchFailed": "未能啟動隔離版,請重試;仍失敗可返回繼續修改。", "timeout": "隔離版啟動逾時,請重試。", - "interrupted": "上次測試已中斷,請重新啟動隔離版。" + "interrupted": "上次測試已中斷,請重新啟動隔離版。", + "stopFailed": "測試版尚未退出,請關閉測試視窗後重試。" }, - "retry": "重試啟動", "startingHint": "啟動完成前暫時無法繼續修改,請等待目前步驟完成。", "currentStep": "目前步驟:{{step}}", "failedStep": "中斷步驟:{{step}}", @@ -13107,6 +13143,8 @@ }, "counts": "共 {{total}} 次 · 待合入 {{pending}} 次 · 已合入 {{integrated}} 次", "filterLabel": "篩選製作歷史", + "mergeTaskTitle": "處理合入衝突", + "revertTaskTitle": "處理撤銷合入衝突", "filters": { "all": "全部製作", "pending": "待合入", @@ -13164,7 +13202,6 @@ "resolve": "處理衝突", "retry": "重試", "build": "產生個人版", - "retryBuild": "重新產生", "end": "結束製作", "retry-prepare": "重試準備", "retry-cleanup": "重試清理" @@ -13184,12 +13221,20 @@ }, "merge": { "getLatest": "同步最新原始碼", + "taskTitle": "處理原始碼更新衝突", "updateHint": "更新官方原始碼,並保留個人改動。", "confirmTitle": "更新到最新原始碼?", - "confirmDescription": "會保留你的個人改動。如有衝突,將透過獨立任務協助處理。", + "confirmDescription": "會保留你的個人改動。如有衝突,會先詢問是否建立任務處理。", + "conflictConfirm": { + "title": "原始碼更新遇到衝突", + "description": "確認後會建立獨立任務,由 Agent 自動處理衝突。取消將放棄本次合入,保留現有個人版。", + "confirm": "確認並解決", + "cancel": "取消更新" + }, "confirm": "更新原始碼", "openTask": "開啟處理任務", "resolve": "處理衝突", + "retryCancel": "重試取消", "resolveHint": "在獨立任務中處理衝突並檢查結果。", "otherAccount": "這項操作由其他帳號發起,請切回該帳號繼續。", "status": { @@ -13199,7 +13244,8 @@ "resolving": "正在處理衝突", "checking": "正在檢查改動…", "merged": "原始碼改動已合入", - "failed": "原始碼合入失敗" + "failed": "原始碼合入失敗", + "cancelled": "已取消原始碼更新" }, "errors": { "busy": "其他操作正在進行,請稍後重試。", @@ -13210,7 +13256,8 @@ "baselineChanged": "個人原始碼已發生變化,請依最新狀態重試。", "checksFailed": "改動未通過檢查,請開啟處理任務繼續。", "interrupted": "操作已中斷,改動已保留,請重試。", - "startFailed": "無法開啟處理任務,請重試。" + "startFailed": "無法開啟處理任務,請重試。", + "cancelFailed": "未能取消本次更新,請重試。個人版原始碼已保留。" } } }, diff --git a/apps/desktop/src/renderer/lib/__tests__/cindyMakeAttention.test.ts b/apps/desktop/src/renderer/lib/__tests__/cindyMakeAttention.test.ts new file mode 100644 index 0000000000..51325c6f35 --- /dev/null +++ b/apps/desktop/src/renderer/lib/__tests__/cindyMakeAttention.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { applyCindyMakeCardAttention } from '../cindyMakeAttention'; +import { + addSessionAttention, + clearSessionAttention, + getSessionAttentionKind, +} from '../sessionAttentionStore'; + +const sessionId = 'make-attention-test'; +const card = (state: Record, action = 'build') => ({ + systemCardType: 'cindy-make-complete', + systemCardData: { + reportedAt: 100, + lastAction: action, + [action === 'test' ? 'test' : 'personal']: state, + }, +}); +afterEach(() => clearSessionAttention(sessionId, { intent: 'explicit' })); + +describe('native Cindy Make result attention', () => { + it('marks a build failure even while viewed, survives navigation, and clears on retry', () => { + const failed = card({ status: 'failed', error: 'buildFailed' }); + applyCindyMakeCardAttention(sessionId, undefined, failed, true); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + clearSessionAttention(sessionId); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + applyCindyMakeCardAttention(sessionId, failed, card({ status: 'waiting' }), true); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + }); + + it.each(['test', 'build'])( + 'marks an unseen %s success once and leaves a viewed result read', + (action) => { + const running = card({ status: action === 'test' ? 'starting' : 'packaging' }, action); + const ready = card({ status: 'ready' }, action); + applyCindyMakeCardAttention(sessionId, running, ready, false); + expect(getSessionAttentionKind(sessionId)).toBe('done'); + clearSessionAttention(sessionId); + applyCindyMakeCardAttention(sessionId, ready, ready, false); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + applyCindyMakeCardAttention(sessionId, running, ready, true); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + }, + ); + + it('does not notify cancellation or normal test closure, but retains interruption errors', () => { + applyCindyMakeCardAttention( + sessionId, + undefined, + card({ status: 'failed', error: 'cancelled' }), + false, + ); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + applyCindyMakeCardAttention(sessionId, undefined, card({ status: 'stopped' }, 'test'), false); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + applyCindyMakeCardAttention( + sessionId, + undefined, + card({ status: 'stopped', error: 'interrupted' }, 'test'), + false, + ); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + }); + + it('clears a handled failure on Continue and does not let stale build failure override a new test', () => { + const failed = card({ status: 'failed' }); + applyCindyMakeCardAttention(sessionId, undefined, failed, false); + applyCindyMakeCardAttention( + sessionId, + failed, + { ...failed, systemCardData: { ...failed.systemCardData, continuedAt: 200 } }, + false, + ); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + const nextTest = card({ status: 'ready' }, 'test'); + nextTest.systemCardData.personal = { status: 'failed' }; + applyCindyMakeCardAttention(sessionId, undefined, nextTest, false); + expect(getSessionAttentionKind(sessionId)).toBe('done'); + }); + + it('preserves another unresolved error instead of replacing it with a success dot', () => { + addSessionAttention(sessionId, 'error'); + applyCindyMakeCardAttention( + sessionId, + card({ status: 'packaging' }), + card({ status: 'ready' }), + false, + ); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + }); + + it('also marks task preparation failures before an Agent turn exists', () => { + const preparation = (status: string) => ({ + systemCardType: 'cindy-make', + systemCardData: { report: { runId: 'run', status, task: { sessionId } } }, + }); + applyCindyMakeCardAttention(sessionId, preparation('running'), preparation('failed'), false); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + applyCindyMakeCardAttention(sessionId, preparation('failed'), preparation('running'), false); + expect(getSessionAttentionKind(sessionId)).toBeUndefined(); + }); + + it('does not mistake a completed environment check with missing tools for success', () => { + applyCindyMakeCardAttention( + sessionId, + undefined, + { + systemCardType: 'cindy-make-doctor', + systemCardData: { + report: { + runId: 'doctor', + status: 'completed', + checks: [{ id: 'git', status: 'missing' }], + }, + }, + }, + false, + ); + expect(getSessionAttentionKind(sessionId)).toBe('error'); + }); +}); diff --git a/apps/desktop/src/renderer/lib/cindyMakeAttention.ts b/apps/desktop/src/renderer/lib/cindyMakeAttention.ts new file mode 100644 index 0000000000..e666435c49 --- /dev/null +++ b/apps/desktop/src/renderer/lib/cindyMakeAttention.ts @@ -0,0 +1,53 @@ +import { + getCindyMakeMessageAttention, + type CindyMakeAttention, +} from '../../shared/cindyMakeAttention'; +import { + addSessionAttention, + clearSessionAttention, + getSessionAttentionKind, +} from './sessionAttentionStore'; + +interface MakeCard { + systemCardType?: string; + systemCardData?: Record; +} + +function attention(card: MakeCard | undefined): CindyMakeAttention | undefined { + if (!card) return; + return getCindyMakeMessageAttention({ + role: 'assistant', + agentMeta: + card.systemCardType === 'cindy-make-complete' + ? { cindyMakeCompletion: card.systemCardData } + : undefined, + content: { __cindyMakeCard: { type: card.systemCardType, data: card.systemCardData } }, + }); +} + +/** Called at the shared local/remote message ingress, independently of card mounting. */ +export function applyCindyMakeCardAttention( + sessionId: string, + previous: MakeCard | undefined, + next: MakeCard, + isViewed: boolean, +): void { + const before = attention(previous); + const after = attention(next); + if (!after || (before?.key === after.key && before.kind === after.kind)) return; + const current = getSessionAttentionKind(sessionId); + if (after.kind === 'error') { + addSessionAttention(sessionId, 'error'); + return; + } + // Only clear a dot owned by this card. An unrelated pending alert keeps priority. + if (before && (before.kind === 'error' || before.kind === 'done') && current === before.kind) + clearSessionAttention(sessionId, { intent: 'explicit' }); + if ( + after.kind === 'done' && + !isViewed && + getSessionAttentionKind(sessionId) !== 'error' && + getSessionAttentionKind(sessionId) !== 'awaiting' + ) + addSessionAttention(sessionId, 'done'); +} diff --git a/apps/desktop/src/renderer/lib/makerChatStore.ts b/apps/desktop/src/renderer/lib/makerChatStore.ts index 98cedfd21e..8bf544ca8f 100644 --- a/apps/desktop/src/renderer/lib/makerChatStore.ts +++ b/apps/desktop/src/renderer/lib/makerChatStore.ts @@ -2,6 +2,7 @@ import { emitTaskTagCatalog } from '@/features/task-tags/taskTagEvents'; import { normalizeTaskTags } from '@cindy/maker-shared'; import type { ImMessageSource } from '../../shared/imMessageSource'; import { readBotAuthorizationCard } from '../../shared/botAuthorization'; +import { applyCindyMakeCardAttention } from './cindyMakeAttention'; import { confirmRemoteUsers, reserveRemoteUser } from './remoteUserHandoff'; import { readRemoteHistoryCache, remoteHistoryCacheWriter } from './remoteHistoryCache'; /** @@ -8335,6 +8336,20 @@ function initGlobalListeners(options: GlobalListenerOptions = {}): void { clearRemoteOptimisticSend(sessionId, mapped.clientId); const current = getOrCreateState(sessionId); const existing = current.messages.find((candidate) => candidate.clientId === mapped.clientId); + if (mapped.systemCardType?.startsWith('cindy-make')) { + // A late update to an older preparation card must not replace the current result. + const existingIndex = existing ? current.messages.indexOf(existing) : -1; + const newerMessage = current.messages.some( + (candidate, index) => + candidate.clientId !== mapped.clientId && + candidate.createdAt && + mapped.createdAt && + (candidate.createdAt > mapped.createdAt || + (candidate.createdAt === mapped.createdAt && existingIndex >= 0 && index > existingIndex)), + ); + if (!newerMessage) + applyCindyMakeCardAttention(sessionId, existing, mapped, _activeViewSessions.has(sessionId)); + } const isLiveToolEcho = existing?.role === mapped.role && (mapped.role === 'tool_use' || mapped.role === 'tool_result'); diff --git a/apps/desktop/src/shared/cindyMakeAttention.ts b/apps/desktop/src/shared/cindyMakeAttention.ts new file mode 100644 index 0000000000..f0b27a2c14 --- /dev/null +++ b/apps/desktop/src/shared/cindyMakeAttention.ts @@ -0,0 +1,73 @@ +/** Native Make results share the task's existing dots without faking Agent turns. */ +export interface CindyMakeAttention { + kind: 'done' | 'error' | 'running' | 'none'; + key: string; +} + +function object(value: unknown): Record | undefined { + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch { + return; + } + } + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +/** Accepts both persisted JSON columns and decoded message broadcasts. */ +export function getCindyMakeMessageAttention(message: { + role?: string; + agentMeta?: unknown; + content?: unknown; +}): CindyMakeAttention | undefined { + if (message.role !== 'assistant') return; + const completion = object(object(message.agentMeta)?.cindyMakeCompletion); + if ( + completion && + typeof completion.reportedAt === 'number' && + Number.isFinite(completion.reportedAt) + ) { + if (completion.continuedAt) return { kind: 'none', key: 'editing' }; + const test = object(completion.test); + const personal = object(completion.personal); + const action = completion.lastAction ?? (personal ? 'build' : test ? 'test' : 'complete'); + const state = action === 'build' ? personal : action === 'test' ? test : undefined; + const key = [completion.reportedAt, action, state?.buildId ?? '', state?.status ?? ''].join( + ':', + ); + if (!state) return { kind: 'done', key }; + if (state.error === 'cancelled') return { kind: 'none', key }; + if (state.status === 'failed' || state.error === 'interrupted') return { kind: 'error', key }; + if (state.status === 'ready') return { kind: 'done', key }; + if (state.status === 'stopped') return { kind: 'none', key }; + if ( + ['starting', 'waiting', 'merging', 'checking', 'packaging', 'publishing'].includes( + String(state.status), + ) + ) + return { kind: 'running', key }; + return; + } + const card = object(object(message.content)?.__cindyMakeCard); + if (card?.type !== 'cindy-make' && card?.type !== 'cindy-make-doctor') return; + const data = object(card.data); + if (data?.modalOnly === true) return; + const report = object(data?.report); + if (!report || typeof report.runId !== 'string') return; + const task = object(report.task); + const key = String(report.runId) + ':' + report.status; + if (task?.finished) return { kind: 'none', key }; + if (task?.cleanupPending || report.status === 'failed') return { kind: 'error', key }; + if (report.status === 'completed') { + const checks = Array.isArray(report.checks) ? report.checks : []; + const failedCheck = checks.some((check) => + ['missing', 'incompatible', 'failed'].includes(String(object(check)?.status)), + ); + return { kind: failedCheck ? 'error' : 'done', key }; + } + if (report.status === 'cancelled') return { kind: 'none', key }; + if (report.status === 'running') return { kind: 'running', key }; +} diff --git a/apps/desktop/src/shared/cindyMakeHistory.ts b/apps/desktop/src/shared/cindyMakeHistory.ts index 0eba8f2c37..f85a7778df 100644 --- a/apps/desktop/src/shared/cindyMakeHistory.ts +++ b/apps/desktop/src/shared/cindyMakeHistory.ts @@ -17,6 +17,8 @@ export interface MakeFeatureReceipt { } export interface MakeHistoryCompletion extends CindyMakeCompletionMeta { id: string; + /** The user prompt that led to this completed editing round. */ + prompt?: string; } export interface MakeHistoryVersion { operationId: string; @@ -138,11 +140,6 @@ export function makeHistoryActions(facts: { return [...actions, 'end']; } if (facts.recoverableFailure) return [...actions, 'retry']; - if ( - (facts.buildFailed || facts.needsBuild) && - (facts.buildSourceAvailable ?? facts.sourceAvailable) - ) - actions.push('build'); if (facts.conflict) return [...actions, 'resolve']; if (facts.lifecycle === 'cleanup') return [...actions, 'retry-cleanup']; if (facts.lifecycle === 'preparing' || facts.lifecycle === 'running') return actions; @@ -177,5 +174,13 @@ export function makeHistoryActions(facts: { } else if (facts.integration === 'integrated' || facts.integration === 'changed') actions.push('revert'); } + if ( + (facts.buildSourceAvailable ?? facts.sourceAvailable) && + (actions.includes('integrate') || + actions.includes('reapply') || + ((facts.buildFailed || facts.needsBuild) && facts.integration !== 'changed') || + (facts.completed && facts.integration === 'integrated')) + ) + actions.push('build'); return actions; } diff --git a/apps/desktop/src/shared/cindyMakeMerge.ts b/apps/desktop/src/shared/cindyMakeMerge.ts index 247d3d85fc..963417fea2 100644 --- a/apps/desktop/src/shared/cindyMakeMerge.ts +++ b/apps/desktop/src/shared/cindyMakeMerge.ts @@ -27,10 +27,19 @@ export type CindyMakeMergeError = | 'baselineChanged' | 'checksFailed' | 'interrupted' - | 'startFailed'; + | 'startFailed' + | 'cancelFailed'; export interface CindyMakeMergeState { id: string; - status: 'fetching' | 'merging' | 'conflict' | 'resolving' | 'checking' | 'merged' | 'failed'; + status: + | 'fetching' + | 'merging' + | 'conflict' + | 'resolving' + | 'checking' + | 'merged' + | 'failed' + | 'cancelled'; ref: string; upstreamCommit: string; baselineCommit?: string; @@ -47,11 +56,15 @@ export interface CindyMakeMergeState { sessionId?: string; /** A retained candidate must not be removed by source preparation/reset. */ hasWorkspace?: boolean; + /** Persisted before cleanup so interruption retries cancellation, never starts a resolution task. */ + cancellationRequested?: boolean; ownedByAnotherAccount?: boolean; error?: CindyMakeMergeError; } -export type CindyMakeMergeAction = 'update' | 'resolve' | 'status'; +export type CindyMakeMergeAction = 'update' | 'resolve' | 'cancel' | 'status'; export interface CindyMakeMergeRequest { action: CindyMakeMergeAction; + /** Required for cancellation; binds a conflict decision to the operation the user saw. */ + operationId?: string; createOptions?: CindyMakeTaskOptions; } diff --git a/apps/desktop/src/shared/cindyMakeMergeTitle.ts b/apps/desktop/src/shared/cindyMakeMergeTitle.ts new file mode 100644 index 0000000000..6b45b417fe --- /dev/null +++ b/apps/desktop/src/shared/cindyMakeMergeTitle.ts @@ -0,0 +1,8 @@ +/** Keep the local creation time visible before the repeated conflict label in narrow task lists. */ +export function formatCindyMakeMergeTitle(title: string, createdAt: number | string): string { + const date = new Date(createdAt); + if (!Number.isFinite(date.getTime())) return title; + const pad = (value: number) => String(value).padStart(2, '0'); + const time = `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; + return `${time} ${title}`; +} diff --git a/apps/desktop/src/shared/cindyMakeSession.ts b/apps/desktop/src/shared/cindyMakeSession.ts index 7c21a71bd6..0b860c4a70 100644 --- a/apps/desktop/src/shared/cindyMakeSession.ts +++ b/apps/desktop/src/shared/cindyMakeSession.ts @@ -51,7 +51,14 @@ export interface CindyMakeTestState { status: 'starting' | 'ready' | 'failed' | 'stopped'; /** Optional startup detail; older completions retain the broad status. */ step?: CindyMakeTestStep; - error?: 'unavailable' | 'changed' | 'environment' | 'launchFailed' | 'timeout' | 'interrupted'; + error?: + | 'unavailable' + | 'changed' + | 'environment' + | 'launchFailed' + | 'timeout' + | 'interrupted' + | 'stopFailed'; } export type CindyMakeTestStep = @@ -59,8 +66,12 @@ export type CindyMakeTestStep = export interface CindyMakePersonalBuildState { status: 'waiting' | 'checking' | 'merging' | 'packaging' | 'publishing' | 'ready' | 'failed'; + /** Optional preparation detail; older clients still display waiting. */ + preparationStep?: 'environment' | 'original'; /** Optional detail within checking; old records/clients retain the broad status. */ checkStep?: 'dependencies' | 'tests' | 'types'; + /** Bounded, structured progress records; raw process output never crosses into the UI. */ + logs?: CindyMakeBuildLogEntry[]; /** Cancellation is pending until owned processes and disposable outputs are cleaned. */ stopping?: boolean; startedAt?: number; @@ -91,6 +102,84 @@ export interface CindyMakePersonalBuildState { | 'interrupted'; } +export type CindyMakeBuildLogStep = + | 'environment' + | 'original' + | 'merging' + | 'checking-dependencies' + | 'checking-tests' + | 'checking-types' + | 'packaging' + | 'publishing' + | 'ready' + | 'failed' + | 'cancelled'; + +export interface CindyMakeBuildLogEntry { + step: CindyMakeBuildLogStep; + at: number; +} + +const CINDY_MAKE_BUILD_LOG_STEPS = new Set([ + 'environment', + 'original', + 'merging', + 'checking-dependencies', + 'checking-tests', + 'checking-types', + 'packaging', + 'publishing', + 'ready', + 'failed', + 'cancelled', +]); + +/** Validate persisted log entries before they cross the Main/Renderer boundary. */ +export function parseCindyMakeBuildLogs(value: unknown): CindyMakeBuildLogEntry[] | undefined { + if (!Array.isArray(value)) return undefined; + const logs = value + .filter( + (entry): entry is { step: unknown; at: unknown } => !!entry && typeof entry === 'object', + ) + .filter( + (entry): entry is CindyMakeBuildLogEntry => + typeof entry.step === 'string' && + CINDY_MAKE_BUILD_LOG_STEPS.has(entry.step as CindyMakeBuildLogStep) && + typeof entry.at === 'number' && + Number.isFinite(entry.at), + ) + .map((entry) => ({ step: entry.step, at: entry.at })) + .slice(-80); + return logs.length ? logs : undefined; +} + +/** Add one stable, localizable entry when a build crosses a visible stage. */ +export function appendCindyMakeBuildLog( + previous: CindyMakePersonalBuildState | undefined, + next: CindyMakePersonalBuildState, + at = Date.now(), +): CindyMakePersonalBuildState { + const step: CindyMakeBuildLogStep | undefined = + next.status === 'waiting' + ? next.preparationStep + : next.status === 'checking' + ? next.checkStep + ? (('checking-' + next.checkStep) as CindyMakeBuildLogStep) + : 'checking-dependencies' + : next.status === 'failed' + ? next.error === 'cancelled' + ? 'cancelled' + : 'failed' + : next.status; + if (!step) return next; + const logs = previous?.logs ?? next.logs ?? []; + if (logs.at(-1)?.step === step) return { ...next, logs }; + return { + ...next, + logs: [...logs, { step, at }].slice(-80), + }; +} + /** Only known failure codes may cross from build processes or saved records into UI. */ export function parseCindyMakeBuildError( value: unknown, diff --git a/docs/cindy-make-upstream.md b/docs/cindy-make-upstream.md index 62815c5b69..21d58de6bc 100644 --- a/docs/cindy-make-upstream.md +++ b/docs/cindy-make-upstream.md @@ -49,14 +49,17 @@ Main 负责网络和 Git,UI 只消费报告;运行版本摘要在结果详 生成个人版时先把功能分支的提交合入 `cindy-personal`,再从这个实际源码目录安装依赖、 检查、构建。临时目录用于检测冲突并形成候选合并提交,不在那里构建。成功合入后的 -个人源码工作区保持干净;构建失败/取消保留已合入提交与任务,不回退源码。 +个人源码工作区保持干净。按 2026-09-20 用户裁决,生成失败/取消时自动撤回尚未生成成功版本的 +连续合入,恢复此前个人源码,保留任务分支、每轮修改与已经生成的版本;再次生成会自动重新合入。 +恢复前核验分支、提交、文件树和工作区,发现并发修改时保留现场并报告清理未完成,不覆盖用户修改。 +撤回过程保留失败提交的本地引用,并持久化待清理记录,确保 Git 已恢复而历史写入失败时可以接着清理。 旧完成记录只有文件树时仍能转换为本地提交。源码存在旧版未提交内容时先保存原 HEAD、 暂存树与文件树;若此前已应用官方文件但分支仍停在旧提交,以已记录的官方基线保存个人 差异,不把大量上游文件误当成个人功能提交。所有旧历史与文件备份通过本地引用保留。 获取官方更新时先固定当前渠道目标、更新本地 main,再在独立分支/worktree 中把个人提交 rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支)。无冲突自动采纳;有冲突 -自动创建独立任务,沿用当前模型设置。冲突解决并完整结束 rebase、校验个人源码未并发 +先弹窗询问,确认后才创建独立任务并沿用当前模型设置;取消则放弃本次合入、保留个人版。冲突解决并完整结束 rebase、校验个人源码未并发 变化后,才更新 cindy-personal。结果是“最新 main + 个人提交”,不是要求两者 hash 相等; 官方更新不再堆积在未提交列表中。旧版遗留冲突现场仍可完成并迁移,新旧路径均不推送。 实现见 [localHistory](../apps/desktop/src/main/cindy-make/localHistory.ts) 与 @@ -68,12 +71,17 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 已进入发送事务或运行中的任务禁止清理源码;清理已开始时拒绝新的发送。 - Settings → Cindy Make 分为“版本控制”和“环境检查”两个页签,版本控制排在首位并默认打开。 工具检查和 Dev 测试开关归环境检查;个人版源码、我的版本和制作历史归版本控制。 + 创建个人版本入口保留在版本控制页签内、概览卡上方。 当前使用版本、切换入口和个人版源码合为一张概览卡,制作历史紧随其后。切换只改变可见面板,保留检查/准备作业、版本列表展开状态与 制作搜索内容;进入版本控制时重新读取版本列表。页签支持方向键、Home/End 及独立面板标识。 - 输入 /cindy-make 需求后,在当前页面弹窗检查环境、准备个人版源码并查询上游; 这段前置流程不创建任务、不创建 worktree;源码准备与 Settings 共用同一流程, 准备 Git 内容后按锁文件预热依赖缓存,不生成个人分支的依赖目录或执行安装脚本。 - 关闭弹窗或选择等待上游不会留下空任务。Settings 的制作入口与侧栏入口复用同一个弹窗。 + 关闭弹窗不会留下空任务。Settings 的制作入口与侧栏入口复用同一个弹窗。 + 前置弹窗通过右上角关闭图标退出,不再单设底部取消;图标、Esc 和遮罩点击均先二次确认, + 提示随当前阶段更新,说明关闭会停止什么、保留什么;环境/源码准备还须说明设置页共享作业也会停止。 + 确认框内选择继续查看不打断准备;正在创建制作任务时暂时禁止关闭。 + 检查完成后,「继续制作个人版」只显示在弹窗底部,不在报告卡中重复;关闭确认期间暂时禁用。 实现与确认边界回归见 [CindyMakePreflightDialog](../apps/desktop/src/renderer/components/cindy-make/CindyMakePreflightDialog.tsx) 及其[测试](../apps/desktop/src/renderer/components/cindy-make/__tests__/CindyMakePreflightDialog.test.tsx)。 - 选择「继续制作个人版」后,先创建带需求标题的任务并保存准备消息,再复核环境、 @@ -84,9 +92,11 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) Git 阶段以及 pnpm 的解析/复用/下载/添加数量;安装开始与安装脚本执行另有状态提示, 不伪造总进度。Settings 和前置弹窗均显示缓存预热的解析/复用/下载数量; 缓存缺失或版本变化时按需补下载,任务 worktree 再复用缓存独立安装并执行安装脚本。 - Settings 源码卡的底部状态常驻:区分等待其他工程操作、Git 检查、上游版本查询和本地源码检查, + Settings 源码准备和更新共用一个状态区域:区分等待其他工程操作、Git 检查、上游版本查询和本地源码检查。 源码目录直接显示,打开和删除按钮在路径行最右侧对齐,不提供源码详情折叠入口。 - 失败或停止时保留结果和原因,按钮为「重试准备」; + 源码准备失败或停止时保留结果和原因,按钮为「重试准备」;更新失败或冲突时显示原因及处理入口。 + 更新成功只提示一次,随后显示个人版与 main 的版本差距,不再常驻「源码改动已合入」或 + 「个人版源码已准备好」。旧更新回执不遮挡后续源码准备与重试;仍有未处理更新工作区时保留冲突处理入口。 卡片进入页面即显示,先发布本机保存的源码摘要,再补 Git 版本与联网查询结果; 不让工具发现或网络查询阻塞整张卡。重新进入保留已有信息,查询失败仍显示本地版本。 各阶段共用同一刷新代次,迟到的查询不能覆盖之后的准备、清理或较新的查询结果。 @@ -135,6 +145,17 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) D-Bus 连接变量,不继承宿主 profile、凭证和 Node 注入;版本切换复用同一白名单。 测试启动单独注入环境检查已验证的 pnpm 绝对入口,不继承宿主的 `npm_execpath`, 避免 Windows 的命令包装脚本从任务目录误找 pnpm 安装文件;已有任务的旧启动脚本同样适用。 + 新源码通过仅限开发隔离实例的 `XDT_CINDY_MAKE_TEST=1` 标记识别测试窗口: + 窗口就绪后复用现有主窗口激活路径带到前台,关闭窗口直接退出测试进程, + 不进入托盘/最小化选择。普通开发版和已安装版本保持原有窗口行为;旧源码不识别 + 该标记时仍沿用原行为。测试版的登录、设置与持久数据保留在原有隔离目录, + 不随关窗删除;源码、依赖和已生成个人版同样保留。每次启动创建独立临时目录, + 仅对子进程覆写 `TMPDIR`/`TMP`/`TEMP`,让新旧启动器的启动与重启标记集中在其中。 + 正常退出、启动失败或停止后回收该目录,目录被替换时拒绝清理;清理失败仅记录固定原因码。 + macOS/Linux 停止 PTY 的进程组,Windows 由 ConPTY 回收所属进程;未收到退出回执时 + 不清理仍在使用的临时文件。继续修改和生成个人版等待退出最多 10 秒,超时明确提示先关闭 + 测试窗口再重试,仍保留工作目录租约,直到收到真实退出回执;不能假报退出后开始修改或构建。 + 宿主正常退出沿用既有有时限的退出清理流程等待回收。 只有与目标目录、commit、隔离名及区域匹配的 ready 回执才显示已启动,不能把它当作 用户验收通过。状态写入该完成记录并沿用消息推送;切换页面不取消启动。 启动卡片另起一行显示当前步骤:等待启动、环境检查、修改核验、隔离实例准备、依赖、 @@ -149,11 +170,18 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) [完成卡回归](../apps/desktop/src/renderer/components/cindy-make/__tests__/CindyMakeTestCard.test.tsx)。 - “生成个人版”同样由用户点击触发:固定本次完成的提交和文件树,在临时工作目录与 当前 cindy-personal 合并,无冲突则将本地合并提交采纳到个人版并保存合入记录。 + 从完成卡生成时先停止该任务正在运行的测试版,等待进程退出及临时文件清理结算后, + 才检查合入状态并开始生成;设置页已有忙碌限制继续阻止测试与生成并行。 旧版仅含文件树的完成记录兼容转换为本地提交。随后依赖安装、相关单测、类型检查与打包都在 `source` 目录的 `cindy-personal` 分支执行,使用既有版本无关打包入口,保留 packaged smoke。 + 数据库历史冻结以个人版提交与托管 `main` 的共同祖先为基线;从版本 tag 克隆、尚无本地 + `main` 时取与 `origin/main` 的共同祖先。向打包子进程传入已解析的完整 commit, + 不直接使用可能更晚的 `origin/main`,也不使用个人版 HEAD 放过历史修改。 校验安装包的区域、架构、commit 与 SHA-256,保存到本机专属产物目录后,重新核对任务 与个人版文件没有变化。冲突不影响个人版原文件;合入后检查/打包失败或取消, - 两个工作目录的源码都保留,重试从已合入记录继续,失败产物不进入版本列表。 + 自动撤回未成功生成版本的合入并清理失败产物,任务工作目录的修改完整保留; + 已发布的可运行版本即使在历史登记前发生中断也视为成功,不会被后续失败清理撤回。 + 再次点击“生成个人版”会重新合入后生成,失败产物不进入版本列表。 同一源码从合入直到成品保存与清理全程持有项目锁,其他源码操作排队;临时合并目录 在结束后清理,个人版源码与依赖保留供下次生成复用。除安装包外,将完整可运行 应用保存成独立快照(Windows 完整目录、macOS 完整 .app),保留签名、执行权限与 @@ -161,9 +189,13 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 源码不删除成品。检查、打包与保存完成后才登记到版本列表,生成不自动启用、安装或发布。 新源码声明启动协议后,完成卡按钮变成“切换到此个人版”;旧源码/旧完成记录继续保留 “查看安装包”,不声称不具备协议的旧程序能够接管当前数据。 +- 桌面制作任务内的准备、测试与生成结果复用现有任务红绿点:失败或中断显示红点,成功且未查看 + 显示绿点;取消与正常关闭测试不算失败。打开任务不会清除失败红点,重试或继续修改后收敛。 + 红点从持久卡片恢复,重启后尚未打开的制作任务也能显示;旧卡片的迟到更新不能覆盖新的结果, + 同一成功结果重复推送不会重新点亮已读绿点。不伪造模型运行或插入额外错误消息。 - 个人版排队、合并、检查、打包及保存期间,完成卡三个操作全部禁用,成功或失败后恢复; “继续修改”不作为生成期间的取消入口。继续修改会停止本次测试进程,退出宿主会取消 - 构建;文件合入已经开始时完成合入记录并保留源码,成品登记完成时保留成功产物及 + 构建;文件合入已经开始时先完成合入记录,再执行失败清理,成品登记完成时保留成功产物及 回执。运行与清理完成前保持工作目录占用,避免误删。应用重启不自动重放启动或构建, 旧在途状态按已中断呈现,可显式重试。手机在已有 Cindy Make 任务中显示同一份准备、测试与生成状态, 包括测试启动步骤、失败重试、继续修改及缺少完成登记时的“检查并继续”。操作由被控电脑执行; @@ -173,7 +205,7 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 提供“停止制作”。停止只中止本次构建进程,并清理临时合并目录、未完成安装包、打包输出和构建元数据; 完成卡与设置页显示同一次构建的身份、进度和停止状态,从任一入口都能停止; 离开页面不会丢失进度,旧构建的停止请求不能取消重试产生的新构建。 - 不回退个人源码、已合入提交、历史记录或已生成版本。历史记录没有安全动作时,Main 返回不可用原因, + 同时撤回本次尚未生成成功版本的合入,保留任务历史与已生成版本。历史记录没有安全动作时,Main 返回不可用原因, 页面必须显示原因,不能留下空白按钮区。 实现与回归见 [testRuntime](../apps/desktop/src/main/cindy-make/testRuntime.ts)、 [testRunner](../apps/desktop/src/main/cindy-make/testRunner.ts)、 @@ -189,7 +221,11 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 启动命令。开发构建和当前运行的打包个人版始终保留 Cindy Make 设置入口,以便返回原版; 原版不存在个人版选择时正常启动;生成过但未切换也不改变默认。启动入口 在数据库打开前分流,显式 `--cindy-version-original` 可恢复原版。已运行的实例复用 - Electron 单实例激活;使用版本管理后 Dev/安装版共享该 profile 的实例锁。 + Electron 单实例激活;使用版本管理后 Dev/安装版共享该 profile 的实例锁。分流阶段凡是 + 让本进程继续开原版的路径都不得等待真实 I/O(登记只做同步读取,个人版自检同步计算摘要, + `original.json` 刷新与选择落盘推迟到取得单实例锁之后):紧随其后加载的 + bootstrap-electron 必须在 Electron ready 之前注册特权协议与 ready 监听,分流阶段一旦 + 让出事件循环,原版就会在启动时直接失败。只有以 `app.exit()` 收尾的交接路径可以等待 I/O。 - 切换先核验完整应用与数据兼容性、检查在途工作,再由独立的无窗口 Electron 交接进程 等待旧进程正常退出并启动目标。返回 Dev 复用既有开发 runner 和 PTY;不以裸 Electron 重启替代 Forge/Vite。只有既有窗口+认证/数据库就绪合同成功后才记住新选择,失败 @@ -218,16 +254,18 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 制作任务标题使用工作目录名称的前四位标记,例如 `[f428] 修改背景色`,不再重复 `[Cindy-Make]` 前缀。新任务直接生成短标记;旧任务在显示层兼容,重命名不重复叠加。 - Settings 的「制作历史」使用可搜索、可筛选的列表/详情双栏;窄容器改为上下排列,列表和 - 详情分别限高滚动,操作区始终可见。按普通制作任务计数,每轮完成回报在详情中展开; + 详情分别限高滚动,操作区始终可见。按普通制作任务计数,每轮完成回报在详情中展开,最新轮次在上并保留原编号; 官方更新与合入/撤销冲突任务不计入制作次数。历史覆盖正在制作与已结束的记录。 - 准备完成、一轮修改完成、源码合入和生成应用分别呈现,不互相冒充。 + 列表只显示一个当前状态;详情只显示一条当前结果或错误,不叠加“已合入/待生成”等内部状态, + 标题与需求相同时不重复显示。仍以实际完成和生成事实为准,不互相冒充。 - 历史按当前数据 owner 持久化在源码目录之外,清理单个工作目录不会删除历史。 老任务从准备卡、完成事实与实际 Git 合入提交核验回填;缺少证据时不伪造撤销依据。 选中记录时核验源码、完成快照与保留的 Git 对象;Main 在操作入场再检查一次。 可以安全清理的历史记录提供“清理任务”入口;它只隐藏该条列表并沿用 Main 的任务清理管线,保留归档交流、 - 已合入提交和可撤销回执。当前正在制作的内容变更只提供查看,完成后提供继续修改、隔离测试与合入;已合入不重复显示合入, - 有新一轮修改后再提供合入,已撤销才提供重新合入。冲突和普通失败分别提供解决、重试。 - 已结束不再显示编辑、测试和重复的“结束制作”,但仍可隐藏历史;有效的合入记录仍可撤销。 + 已合入提交和历史回执。当前正在制作的内容变更只提供查看,完成后提供生成个人版、隔离测试与继续修改; + 可继续修改时不重复提供“打开任务”。主操作区不暴露合入、撤销合入和重新合入按钮, + 生成失败的撤回由失败清理负责。冲突仍提供处理入口。 + 已结束不再显示编辑、测试和重复的“结束制作”,但仍可隐藏历史。 所有按钮复用统一边框。 Main 区分全局忙碌与目标任务忙碌:当前任务仍在准备、运行、测试、冲突处理或清理时,清理请求 立即返回 busy,详情保留原因提示;另一个任务占用项目资源时,空闲历史任务仍保留结束/重试清理, @@ -238,11 +276,12 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) 目录,原个人源码保留。官方 rebase 改写 hash 后仍通过保留的文件差异处理,不能依据旧 commit 的祖先关系误判已经撤销或无法撤销。Git 采纳和历史写入之间中断可显式重试, 历史回执按操作 ID 去重;重启不静默重放写操作。 -- 「合入个人版」不自动打包;页面统一的「生成个人版」从当前个人源码构建,无需仍存在 - 的制作工作目录,也不创建虚假的任务。生成时在源码锁内记录实际包含的合入/撤销操作, - 成功后刷新版本列表。记录区分已合入待生成与已经生成,生成时间只显示真实保存的时间; - 旧版缺失生成时间时仅显示已生成。生成失败保留源码,原有应用成品不变。 - 生成入口说明只包含当前个人源码,未合入的历史改动不自动加入;检查期间分别显示安装依赖、 +- 制作历史的「生成个人版」自动合入所选任务已经完成的修改,再从当前个人源码构建; + 当前完成轮复用任务完成卡的同一生成流程。已结束的任务可使用保留的完成快照,无需仍存在 + 的制作工作目录,也不创建虚假的任务。生成时在源码锁内记录实际包含的操作,成功后刷新版本列表。 + 按钮始终叫「生成个人版」,失败后保持同一入口,不另设「重新生成」。失败或取消自动撤回 + 尚未成功生成的合入,保留任务修改与原有应用成品;清理未完成时显示原因,再次生成前继续清理。 + 独立的全局生成入口仍只构建当前个人源码,不自动加入其它历史任务。检查期间分别显示安装依赖、 自动测试和类型检查,加载时保留可见按钮文字,失败保留已知原因。旧记录缺少检查子阶段时 回退到检查中,不改变已有状态含义。 未声明托管版本启动协议的旧源码仍提供「查看安装包」,不声称可在版本列表中切换。 @@ -296,9 +335,10 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) - Main 拉取固定官方提交,保存个人历史/文件与 main 的备份,再更新 main。 旧个人目录的未提交修改先转成本地提交;有未结束的 Git 操作或 main 含非目标提交时停止。 - rebase 在托管的独立目录中进行;只重放官方基线之后的个人提交,main 不接收个人提交。 - 无冲突自动采纳;冲突时原个人源码保持可用,自动创建一个独立任务。重复点击不重复创建。 + 无冲突自动采纳;冲突时原个人源码保持可用,先弹出二次确认。确认后创建独立任务, + 由 Agent 自动处理并打开该任务;取消则放弃本次合入,不创建任务。重复点击不重复创建。 个人历史中的 merge 提交若含额外修改/冲突处理结果,普通 rebase 可能漏掉这些内容; - Main 通过 remerge 差异识别后,即使 rebase 没有文本冲突也先创建核对任务,保留原个人目录, + Main 通过 remerge 差异识别后,即使 rebase 没有文本冲突也先询问,确认后才创建核对任务,保留原个人目录, 待核对原功能并补回遗漏后再采纳。 - 冲突任务使用独立 source `cindy-make-merge`,归入 Cindy Make 侧栏分组,标题使用 工作目录的四位标记。它不继承普通制作任务的每轮说明、完成回报工具、隔离测试或 @@ -311,9 +351,16 @@ rebase 到新官方基线(禁止 update-refs 自动改动其他任务分支) continuation 边界不应用结果。旧文件合并现场可保留 MERGE_HEAD,解决后由宿主将个人 差异迁移为本地提交,既有历史保留备份。 - 操作和任务绑定持久化到 userData;重启不静默重放 Git 写操作,重复点击复用任务。 + 更新从发起时即绑定账号;确认和取消携带本次操作 ID,旧弹窗不能处置后来的更新。 + 离开设置或切换账号时关闭待确认弹窗、保留冲突现场;再次进入可从「处理冲突」重新选择。 创建任务前先持久化任务 ID;账号切换后不发送旧请求、不暴露另一账号的任务入口。 已删除或归档的任务由下一次显式解决冲突操作创建新任务,继续使用保留的工作目录。 未完成的合并禁止清理源码;缺失的合并目录不会被普通恢复流程重建为空目录。 +- 取消只适用于尚未创建处理任务的官方更新:核验托管目录及分支归属,退出候选目录中的 + rebase/merge,再正常移除临时 worktree,以原提交值原子删除临时分支。个人源码、 + 已获取的官方 main 和恢复备份继续保留。先持久化取消选择,再执行清理;清理失败或中断 + 保留原因和「重试取消」入口,重启不会误开解决任务,清理完成前仍阻止后续更新、合入和生成。 + 不能强制删除新增文件、被锁定或已由任务使用的目录。取消完成后可再次更新。 - 没有任务的自动合并成功后,仅在候选提交和文件树与已采纳结果一致、提交仍被个人版包含时 移除临时 worktree,再核对分支没有被其他 worktree 使用,以原提交值原子删除对应的 `cindy-merge/` 临时分支;`refs/cindy-make/backups/*` 等恢复引用继续保留。冲突任务的目录和 diff --git a/docs/design-rules/DESIGN.md b/docs/design-rules/DESIGN.md index 90ac15a82e..7d34afe89e 100644 --- a/docs/design-rules/DESIGN.md +++ b/docs/design-rules/DESIGN.md @@ -95,7 +95,7 @@ The grayscale rule is near-absolute. The following are the **only** sanctioned n > **Additional narrowly-scoped exceptions** (documented in their respective component specs, do NOT generalize as system semantic colors): > -> - **Cindy Make source comparison** — the owner-requested one-line comparison (2026-09-17) uses `--text-secondary` for the current hash, `--text-tertiary` for the arrow icon, and `--status-success` for the verified latest version and its parenthesized commit difference. This green identifies the official target version, not the state of the local checkout. Unavailable lookups stay neutral. Both modes use the existing semantic tokens; no other version surface inherits this treatment. +> - **Cindy Make source comparison** — the owner-approved source overview (2026-09-20) combines local main and the online target in one row, with personal and its comparison to local main below. Matching main hashes appear once in `--status-success`; differing hashes show the local value in `--text-secondary` → the verified online target in `--status-success`, with a `--text-tertiary` arrow. The online green identifies the official target even when local main is behind; difference counts remain explicit. Unavailable lookups stay neutral. Both modes use the existing semantic tokens; no other version surface inherits this treatment. > - **CINDY checked Switch** — `--switch-track-on` uses blue `#417CDD` in CINDY Light and Dark (owner decision 2026-09-15, §15.17). This is an enabled-state signal, independently adjustable from focus, caret and Auto Approval despite sharing their current hue. Other themes retain their own checked-track colors. > - **Windows taskbar attention badge** — the native OS overlay uses a fixed red background (`#D91F37`) and white digits with no outline (`#FFFFFF`) in both Light and Dark taskbars. This is the app-wide count of active tasks needing attention, not an error-only status. The image-specific semantic pair lives in `apps/desktop/src/main/windowsBadgeIcon.ts`; it is not an in-app theme token. Keep a 16-logical-pixel transparent canvas and center the circle and digits at 80% of their original size (user refinement, 2026-09-16). Render using the highest connected display scale and pass that physical-size image directly to the Windows Shell through the native taskbar bridge (Electron 41 otherwise forces overlays to 16 physical pixels). Redraw after display/DPI changes and taskbar recreation; keep the multi-resolution Electron overlay as a failure fallback. Use a circle for one digit, a rounded square for multiple digits, and `99+` above 99. The accessibility description keeps the exact total. Only the OS image may use its fitted 8–12px system-font digits; web typography and status-dot color rules are unchanged. > - **Toast Info / Success / Warning / Error** — `#417CDD` / `#2AAE5B` / `#F3A115` / `#D91F37`(finalized 2026-07-17; Toast exemption lifted) — used ONLY on the 16×16 lucide icon inside Toast pill notifications. The pill body (background, text, border, close icon) remains strictly grayscale. Info blue #417CDD equals the focus-ring / Auto Approval value (originally #3B82F6, added 2026-07-14, now finalized); success/warning/error equal the global status colors (done green / status error / warning foreground). @@ -335,6 +335,8 @@ DS-11: short notifications keep their pill frame. When the rendered message or a Reference implementation: `apps/desktop/src/renderer/components/ui/confirm-dialog.tsx` (the shared confirm dialog); new dialogs reuse its structure — do not invent a parallel one. +**Cindy Make preflight exception (owner decision, 2026-09-20):** this progress dialog uses a top-right × instead of a footer Cancel. The header stays visible while the report scrolls. The ×, Esc and scrim all open the same confirmation, with live copy distinguishing environment preparation, source preparation, upstream search and finished checks. Explain that stopping shared preparation also stops its Settings projection and that prepared resources are kept. Creating the build session temporarily disables dismissal. This exception supersedes the general closing-affordance rule below only for `CindyMakePreflightDialog`; the request form keeps its existing footer. + - **Overlay**: the full-screen scrim uses the `--overlay-modal` token (ConfirmDialog's current `neutral-900/40` hardcoded pair is legacy — **new dialogs always use the token**; do not copy the legacy pair). - **Tooltips opened inside the modal** (2026-09-08, DS-6 review fix): the shared `Tip` portals its content to `document.body` on the default `z-[60]` layer — **below** the `z-[10000]` modal overlay, so an unraised tooltip is covered by its own dialog. Any `Tip` whose trigger lives inside a modal must raise its content above the host dialog: pass `contentClassName="z-[10001]"` to `Tip`, and `secretTipContentClassName="z-[10001]"` on `Input` for the password reveal button (both DS-6 forms do). This mirrors the existing `z-[10001]` popover/dropdown convention inside `z-[10000]` dialogs. - **Container**: a container — 12px radius (`rounded-xl`), `--confirm-bg`, `--confirm-shadow`, 16px padding (`p-4`), centered. Width: confirm/notice dialogs ≈ 400px (`max-w-[400px]`); dialogs with inputs/forms may widen to ≈ 460px and shrink with the viewport (`min(460px, 100vw-32px)`). The DS-6 multi-runtime provider and MCP forms use 600px with the same 16px viewport gutters and a scrollable body capped within 88vh. diff --git a/docs/design-rules/design-decision-log.md b/docs/design-rules/design-decision-log.md index 0eba9792c2..3128ea6f62 100644 --- a/docs/design-rules/design-decision-log.md +++ b/docs/design-rules/design-decision-log.md @@ -12,6 +12,12 @@ ## 2026-09 +- **09-20 Cindy Make 的 main 版本合为一行(后续修订)**——用户要求本地 main 与线上 main 合并显示:相同 SHA 只显示一次并用绿色;不同时显示灰色本地 SHA → 绿色线上 SHA,保留提交差距。personal 独占下一行,继续显示其 SHA 与本地 main 的关系,取代此前三行排列。复用 `text-secondary`、`text-tertiary`、`status-success`,两种主题同样适用;落点为 `DESIGN.md §2` 与 `CindyMakeSourceDetails`。此条记录用户方向授权,不表示实机视觉验收。 + +- **09-20 Cindy Make 合并上游概览布局**——用户确认个人版源码、当前版本和切换入口合并到版本控制概览,保留该页创建入口;正常时显示实际版本差距,更新成功仅提示一次,进度及异常共用状态区域。前置弹窗保留右上角关闭与状态确认,继续制作按钮移到底部。版本对比颜色角色沿用语义 token,见 `DESIGN.md §2`;行为与回归见 `docs/cindy-make-upstream.md`。方向授权不等于实机视觉验收。 + +- **09-20** **Cindy Make 前置准备弹窗改用右上角关闭图标**——用户指出准备结果下方单独一行「取消」显得奇怪,要求改为右上角关闭图标,并根据当前状态二次提示。仅此前置弹窗移除底部取消;关闭图标、Esc 和遮罩点击统一确认,实时说明环境/源码准备、上游查询与已结束状态下的关闭影响,创建任务期间禁止关闭。标题及关闭入口固定,报告单独滚动。现行落点:`DESIGN.md §4 Dialog & Modal` 的局部例外与 `CindyMakePreflightDialog.test.tsx`;这是方向授权,不代表实机视觉已验收。 + - **09-18 搜索命中层级(Issue #4650,用户授权实施)**:全局 Ctrl+F 普通与当前命中原本同色,且两模式底色与内容表面接近。增强普通命中,新增当前命中的独立金色背景与深色文字;全局保留下划线,文件预览与编辑器保留描边,统一消费搜索语义 token。旧 ID 与显式主题覆盖保留。方向授权不等于最终实机验收。落点:`DESIGN.md §10`、`themes/colors.ts`、颜色冻结快照与 `searchHighlightContrast.test.ts`。 - **09-17(撤销 CINDY placeholder 降对比度)**:用户明确要求撤销提交 `7bf645447cf9ae8feeffb781220d44aab79cde45`。CINDY Light / Dark 的 `text-placeholder` 恢复为 `#6B6B67` / `#C1C1C1`,DTCG 数值源、内置主题与独立冻结预期同步恢复;取代 09-16 降低占位文字显著程度的决定。09-16 原记录作为历史保留,Cindy Make 与后续 Switch 改动不受影响。现行规则见 `DESIGN.md §4 / §15`;本条不代表 Light/Dark 实机视觉验收。