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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 25 additions & 1 deletion packages/core/src/deepagent/atomic-write.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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.
Expand Down
36 changes: 35 additions & 1 deletion packages/core/src/deepagent/document-store.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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 ?? "<unreadable>", 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
Expand Down
Binary file modified packages/core/src/deepagent/durable-knowledge-store.ts
Binary file not shown.
20 changes: 20 additions & 0 deletions packages/core/test/deepagent/document-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand All @@ -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:",
Expand Down
21 changes: 19 additions & 2 deletions packages/desktop/scripts/audit-server-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
Expand Down
24 changes: 17 additions & 7 deletions packages/desktop/scripts/subagents-smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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) {
Expand Down Expand Up @@ -87,9 +98,8 @@ async function listSessions(server: Awaited<ReturnType<typeof api>>) {

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: {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading