diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 74f35a64..96a877ee 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -142,6 +142,14 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: npx electron-builder ${{ matrix.platform_flag }} --publish never --config electron-builder.config.ts + - name: Smoke test packaged macOS app + if: matrix.group == 'mac' + working-directory: packages/desktop + run: | + executable=$(find dist/mac-arm64 -maxdepth 4 -type f -path '*/Contents/MacOS/DeepAgent Code*' -perm +111 -print -quit) + test -n "$executable" + DEEPAGENT_CODE_DESKTOP_EXECUTABLE="$executable" bun run test:subagents-cold-start + - name: Upload package artifacts uses: actions/upload-artifact@v4 with: diff --git a/bun.lock b/bun.lock index bbbb4deb..20cbf817 100644 --- a/bun.lock +++ b/bun.lock @@ -349,7 +349,6 @@ "name": "@deepagent-code/desktop", "version": "1.4.4", "dependencies": { - "@deepagent-code/core": "workspace:*", "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -364,6 +363,7 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@deepagent-code/app": "workspace:*", + "@deepagent-code/core": "workspace:*", "@deepagent-code/ui": "workspace:*", "@jridgewell/trace-mapping": "0.3.31", "@playwright/test": "catalog:", diff --git a/packages/core/src/deepagent/atomic-write.ts b/packages/core/src/deepagent/atomic-write.ts index 861bf101..d038ebb9 100644 --- a/packages/core/src/deepagent/atomic-write.ts +++ b/packages/core/src/deepagent/atomic-write.ts @@ -1,4 +1,14 @@ -import { closeSync, fsyncSync, mkdirSync, openSync, renameSync, rmSync, writeSync } from "node:fs" +import { + closeSync, + fsyncSync, + linkSync, + mkdirSync, + openSync, + renameSync, + rmSync, + writeFileSync, + writeSync, +} from "node:fs" import { randomUUID } from "node:crypto" import path from "node:path" @@ -69,6 +79,20 @@ export const writeFileExclusive = (file: string, content: string): void => { } } +// Built-in domain-pack seeds are immutable packaged inputs and can be regenerated after a crash. +// Keep each visible file atomic, but avoid two fsyncs per seed during first-run corpus installation. +export const writeRecoverableFileExclusive = (file: string, content: string): void => { + const dir = path.dirname(file) + mkdirSync(dir, { recursive: true }) + const tmp = path.join(dir, `.${path.basename(file)}.seed-${process.pid}-${randomUUID()}`) + try { + writeFileSync(tmp, content, "utf8") + linkSync(tmp, file) + } finally { + rmSync(tmp, { force: true }) + } +} + // Best-effort directory fsync so a rename/create is durable. Not all platforms/filesystems permit // opening a directory for fsync (Windows throws EPERM/EISDIR, some FUSE mounts reject it); a failure // here does not compromise the file body (already fsync'd) so it is intentionally swallowed. diff --git a/packages/core/src/deepagent/document-store.ts b/packages/core/src/deepagent/document-store.ts index 0376a683..1f9e9e45 100644 --- a/packages/core/src/deepagent/document-store.ts +++ b/packages/core/src/deepagent/document-store.ts @@ -1,7 +1,7 @@ import { mkdirSync, readdirSync, readFileSync, existsSync } from "node:fs" import { createHash } from "node:crypto" import path from "node:path" -import { writeFileAtomic, writeFileExclusive } from "./atomic-write" +import { writeFileAtomic, writeFileExclusive, writeRecoverableFileExclusive } from "./atomic-write" // V3 Document System (docs/28): the bedrock. All persistent state is a typed-document // graph — small files, content-addressed, append-only with a supersede chain, bidirectional @@ -368,6 +368,26 @@ export class DocumentStore { return next } + // Trusted built-in corpus files are recoverable from the packaged domain packs. New seeds can + // therefore skip per-file fsync while retaining atomic visibility; updates still use the normal + // append-only durable path so shipped corpus revisions preserve version history. + seedActive(input: CreateDocInput): Doc { + const cur = this.findLogical(input) + if (cur) { + const doc = this.upsert(input) + if (doc.status !== "active") this.setStatus(doc.id, "active") + return this.get(doc.id)! + } + + const id = this.allocateId(input.type, input.domain ?? null, input.idSlug, input.description) + let doc = { ...this.docFromInput(id, 1, input), status: "active" as const } + this.assertKnowledgeConfidence(doc) + this.assertLinkTargets(doc.links) + doc = { ...doc, hash: computeHash(doc) } + this.persistRecoverable(doc) + return doc + } + update(id: string, body: string, links?: readonly DocLink[]): Doc { const cur = this.get(id) if (!cur) throw new Error(`update: unknown doc ${id}`) @@ -607,6 +627,20 @@ export class DocumentStore { } this.indexDoc(doc) } + private persistRecoverable(doc: Doc): void { + const dir = path.join(this.root, "docs", doc.type) + mkdirSync(dir, { recursive: true }) + const file = path.join(dir, `${idToFile(doc.id)}@v${doc.version}.json`) + try { + writeRecoverableFileExclusive(file, JSON.stringify(doc, null, 2)) + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") throw error + const existing = this.readVersionFile(file) + if (!existing || existing.hash !== doc.hash) + throw new DocumentConflictError(doc.id, doc.version, existing?.hash ?? "", doc.hash) + } + this.indexDoc(doc) + } private replace(doc: Doc): void { // rewrites the SAME version in place with new status/superseded_by; rehash so INV-2 holds. This // is an intentional overwrite (not a new version), so it uses the crash-safe atomic OVERWRITE diff --git a/packages/core/src/deepagent/durable-knowledge-store.ts b/packages/core/src/deepagent/durable-knowledge-store.ts index ab364124..b237e8d2 100644 Binary files a/packages/core/src/deepagent/durable-knowledge-store.ts and b/packages/core/src/deepagent/durable-knowledge-store.ts differ diff --git a/packages/core/test/deepagent/document-store.test.ts b/packages/core/test/deepagent/document-store.test.ts index 45c004a1..4532bed0 100644 --- a/packages/core/test/deepagent/document-store.test.ts +++ b/packages/core/test/deepagent/document-store.test.ts @@ -174,6 +174,26 @@ describe("V3 DocumentStore", () => { // overwrites the same version atomically (temp+fsync+rename). These tests pin the CAS + durability // behavior that H32-1 (v4.0.4) builds on. describe("F30-1 DocumentStore CAS + atomic durability", () => { + test("recoverable built-in seeds retain active status and exclusive-create CAS", () => { + const h1 = new DocumentStore(root) + const h2 = new DocumentStore(root) + const input = { + type: "strategy" as const, + scope: "durable", + body: "trusted built-in strategy", + description: "built-in strategy", + idSlug: "built-in-strategy", + confidence: { evidence_strength: "strong" as const, support_count: 1 }, + provenance: { source: "human" as const }, + } + const first = h1.seedActive(input) + const concurrent = h2.seedActive(input) + expect(first.status).toBe("active") + expect(concurrent.hash).toBe(first.hash) + expect(new DocumentStore(root).get(first.id)).toEqual(first) + expect(readdirSync(path.join(root, "docs", "strategy"))).toEqual([`${first.id.replaceAll(":", "__")}@v1.json`]) + }) + test("normal single-writer flow is unchanged (create + updates land byte-identically)", () => { const a = store.create(design("v1")) const a2 = store.update(a.id, "v2") diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index ba308c7e..a177fdaf 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -62,6 +62,7 @@ const getBase = (): Configuration => ({ "resources/*.metainfo.xml", "resources/deepagent-code-cli*", ], + asarUnpack: ["out/main/chunks/node.js"], beforePack: () => auditPackageInputs(path.dirname(fileURLToPath(import.meta.url))), extraResources: [ { diff --git a/packages/desktop/electron.vite.config.ts b/packages/desktop/electron.vite.config.ts index 9045594d..cc911e5a 100644 --- a/packages/desktop/electron.vite.config.ts +++ b/packages/desktop/electron.vite.config.ts @@ -39,7 +39,7 @@ export default defineConfig(({ command }) => ({ rollupOptions: { input: { index: "src/main/index.ts", sidecar: "src/main/sidecar.ts" }, }, - externalizeDeps: { include: ["@lydell/node-pty"] }, + externalizeDeps: { exclude: ["@deepagent-code/core"], include: ["@lydell/node-pty"] }, }, plugins: [ { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9c7497bb..ee695d87 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -36,7 +36,6 @@ }, "main": "./out/main/index.js", "dependencies": { - "@deepagent-code/core": "workspace:*", "@lydell/node-pty": "catalog:", "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -51,6 +50,7 @@ "devDependencies": { "@actions/artifact": "4.0.0", "@deepagent-code/app": "workspace:*", + "@deepagent-code/core": "workspace:*", "@deepagent-code/ui": "workspace:*", "@jridgewell/trace-mapping": "0.3.31", "@playwright/test": "catalog:", diff --git a/packages/desktop/scripts/audit-server-bundle.ts b/packages/desktop/scripts/audit-server-bundle.ts index da8e9043..c732fdb6 100644 --- a/packages/desktop/scripts/audit-server-bundle.ts +++ b/packages/desktop/scripts/audit-server-bundle.ts @@ -4,8 +4,9 @@ import { strict as assert } from "node:assert" import { readdir } from "node:fs/promises" import path from "node:path" -const chunks = path.resolve("out/main/chunks") -const sidecar = await Bun.file(path.resolve("out/main/sidecar.js")).text() +const main = path.resolve("out/main") +const chunks = path.join(main, "chunks") +const sidecar = await Bun.file(path.join(main, "sidecar.js")).text() const server = Bun.file(path.join(chunks, "node.js")) const sourceMap = Bun.file(path.join(chunks, "node.js.map")) const files = await readdir(chunks) @@ -18,6 +19,22 @@ assert.deepEqual( [], "Rollup must not emit a transformed copy of the server bundle", ) +assert.deepEqual( + ( + await Promise.all( + [path.join(main, "index.js"), path.join(main, "sidecar.js"), ...files.map((file) => path.join(chunks, file))] + .filter((file) => file.endsWith(".js")) + .map(async (file) => + new Bun.Transpiler({ loader: "js" }) + .scanImports(await Bun.file(file).text()) + .filter((item) => item.path.startsWith("@deepagent-code/")) + .map((item) => `${path.relative(main, file)} -> ${item.path}`), + ), + ) + ).flat(), + [], + "Packaged main process must not import TypeScript workspace packages", +) const source = await server.text() assert.match(source, /sourceMappingURL=node\.js\.map/, "external server bundle is not linked to its source map") diff --git a/packages/desktop/scripts/subagents-smoke.ts b/packages/desktop/scripts/subagents-smoke.ts index ae971119..b7af0644 100644 --- a/packages/desktop/scripts/subagents-smoke.ts +++ b/packages/desktop/scripts/subagents-smoke.ts @@ -8,6 +8,7 @@ import { _electron as electron, type ElectronApplication, type Page } from "@pla const root = await realpath(await mkdtemp(join(tmpdir(), "deepagent-code-subagents-smoke-"))) const workspace = join(root, "workspace") const main = resolve("out/main/index.js") +const packagedExecutable = process.env.DEEPAGENT_CODE_DESKTOP_EXECUTABLE await mkdir(workspace, { recursive: true }) const env = Object.fromEntries( @@ -40,7 +41,13 @@ const api = (page: Page) => page.evaluate(() => (window as unknown as { api: Des let activeApp: ElectronApplication | undefined async function launch() { - const app = await electron.launch({ args: [main], env, timeout: 30_000 }) + const started = performance.now() + const app = await electron.launch({ + args: packagedExecutable ? [] : [main], + ...(packagedExecutable ? { executablePath: packagedExecutable } : {}), + env, + timeout: 30_000, + }) activeApp = app console.log( "Electron main launched", @@ -51,7 +58,11 @@ async function launch() { ) const page = await app.firstWindow({ timeout: 30_000 }) await page.waitForFunction(() => Boolean((window as unknown as { api?: DesktopAPI }).api)) - return { app, page } + const server = await api(page) + const startupMs = Math.round(performance.now() - started) + console.log("Electron startup ready", { packaged: Boolean(packagedExecutable), startupMs }) + if (packagedExecutable) assert.equal(startupMs < 20_000, true, `packaged startup took ${startupMs}ms`) + return { app, page, server } } async function close(app: ElectronApplication) { @@ -87,9 +98,8 @@ async function listSessions(server: Awaited>) { try { const first = await launch() - const server = await api(first.page) - const parent = await createSession(server, { title: "Cold-start parent" }) - const child = await createSession(server, { + const parent = await createSession(first.server, { title: "Cold-start parent" }) + const child = await createSession(first.server, { title: "Cold-start researcher", parentID: parent.id, metadata: { @@ -104,7 +114,7 @@ try { }, }, }) - const firstSessions = await listSessions(server) + const firstSessions = await listSessions(first.server) assert.equal( firstSessions.some((session) => session.id === parent.id), true, @@ -146,7 +156,7 @@ try { second.page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()) }) - const sessions = await listSessions(await api(second.page)) + const sessions = await listSessions(second.server) assert.equal( sessions.some((session) => session.id === parent.id), true,