Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ff494c6
Replace profile JSON persistence with a SQLite authority
Sep 23, 2026
95f5e46
Reduce SQLite profile allocations and write only transferred domains
Sep 23, 2026
a29e17b
Accelerate large profile recovery copies on macOS
Sep 23, 2026
dbb154e
Remove duplicate profile checkpoint work and batch recovery copies
Sep 24, 2026
96a793c
Persist live profile SQLite state in a dedicated worker
Sep 24, 2026
64e8b86
Harden profile restart and protected-setting persistence
Sep 24, 2026
5a0445b
Harden profile migration and rollback while simplifying persistence l…
Sep 24, 2026
1c058af
Finish SQLite recovery startup and base PR regression coverage
Sep 24, 2026
814cd95
Fix SSH output routing across durable reconnect binding
Sep 24, 2026
831e444
Retire durable bindings for terminals that exit during spawn
Sep 24, 2026
a2aab44
Keep successful spawn publication synchronous
Sep 24, 2026
2c4961f
Retain database and startup evidence for crash restart failures
Sep 24, 2026
eb030ed
Persist terminal activity before acknowledging a pane binding
Sep 24, 2026
c0a762c
Keep crash hydration regression outside renderer compilation
Sep 24, 2026
c977d29
fix: fence pending profile retirement and recovery work
Sep 24, 2026
f7540ca
test: use portable CLI source paths
Sep 24, 2026
82a12f9
refactor: reuse profile recovery and settings validation
Sep 24, 2026
bbc581e
fix: drain admitted terminal mutations and restore failed retirements
Sep 24, 2026
1312d98
refactor: simplify deployment command forwarding
Sep 25, 2026
e5319cf
fix: recover profile maintenance and preserve shutdown compatibility
Sep 25, 2026
f46d73a
fix: await durable automation writes before dispatch and acknowledgement
Sep 25, 2026
9512a92
fix: reconcile manual automation requests interrupted before launch
Sep 25, 2026
c6dc315
fix: keep profile saving active after terminal close refusals
Sep 25, 2026
ab7db3b
test: remove compatibility exports after clean profile shutdown
Sep 25, 2026
c277cda
test: observe SQLite authority in restart and startup journeys
Sep 25, 2026
d40b7b8
Coalesce queued profile durability snapshots
Sep 25, 2026
4248fee
Keep profile writer usable after export preparation failures
Sep 25, 2026
01ea72a
fix: preserve getter edits across coalesced profile flushes
Sep 25, 2026
b394819
fix: preserve profile saving through cancellation and recovery failures
Sep 25, 2026
b94142b
fix: keep cloned host identities from reclaiming live profile owners
Sep 25, 2026
59bae52
test: await durable state in pane restart integration
Sep 25, 2026
18f1c41
fix: show Linux startup failures after Electron is ready
Sep 25, 2026
9325aa1
fix: require live orphan evidence before adopting a terminal
Sep 25, 2026
6924559
test: close heartbeat clients before removing browser globals
Sep 25, 2026
5ceeae4
test: distinguish transient relay descriptors from accumulation
Sep 25, 2026
92b1ded
fix: withhold orphan claims until terminal spawn admission settles
Sep 25, 2026
0013333
test: wait for terminal mount before synthetic background events
Sep 25, 2026
8cfb592
fix: route SSH reconnects after early connect replies
Sep 25, 2026
5f0ee15
fix: preserve profile saving and terminal ownership through recovery
Sep 25, 2026
19057c7
test: qualify persistence fixes independently of Bun
Sep 25, 2026
7034db2
fix: preserve live terminals through failed binding saves
Sep 25, 2026
ca8620e
refactor(persistence): simplify store setup and name flush policies
Sep 26, 2026
80e8a86
test(e2e): retry collected startup evaluation promises
Sep 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
30 changes: 25 additions & 5 deletions config/build-plugins/plain-node-entry-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,33 @@ type OutputChunk = Rollup.OutputChunk
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.

// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher, WSL filesystem and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
// The CLI loads these paths after electron-vite replaces out/main.
export const CLI_MAIN_ENTRY_NAMES = [
'agent-hooks/managed-agent-hook-controls',
'codex/managed-home-shell-preflight',
'claude-accounts/keychain',
...[
'access',
'active-location',
'storage-classification',
'offline-settings',
'export-path',
'backup-path',
'database-recovery',
'domain-reader',
'recovery',
'recovery-command'
].map((module) => `persistence/profile-state/profile-state-${module}`),
'startup/http1-compatibility-marker'
] as const

// Plain-Node processes and CLI modules cannot load Electron's API.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'wsl-transcript-fs-process-entry',
'agent-hooks/managed-agent-hook-controls'
...CLI_MAIN_ENTRY_NAMES
] as const

// Entries executed as worker threads of the main process. Electron's module is
Expand All @@ -41,7 +59,9 @@ const WORKER_THREAD_ENTRY_NAMES = [
'session-scanner-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry',
'usage-scan-worker-entry'
'usage-scan-worker-entry',
'profile-state-backup-worker-entry',
Comment thread
OrcaWin marked this conversation as resolved.
'profile-state-writer-worker-entry'
] as const

export const GUARDED_ENTRY_NAMES = [
Expand Down
2 changes: 2 additions & 0 deletions config/electron-builder.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ module.exports = {
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/persistence/profile-state/**',
'out/main/startup/http1-compatibility-marker.js',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/wsl-transcript-fs-process-entry.js',
Expand Down
36 changes: 24 additions & 12 deletions config/scripts/build-orcad.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { arch, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import process from 'node:process'
import { smokeProfileStateWorkers } from './profile-state-worker-smoke.mjs'
import {
ORCAD_VERSION_FILENAME,
ORCAD_RIPGREP_ARTIFACTS
Expand Down Expand Up @@ -99,10 +100,8 @@ cpSync(join(ROOT, 'resources', 'licenses', 'ripgrep'), join(OUT_DIR, 'ripgrep',
recursive: true
})

/** Why one call per child and not one `outdir` build: esbuild mirrors each entry's source
* directory under `outdir`, and both children must land flat beside orcad.js — that is where
* their runtime resolvers look for them. */
function buildForkedChild(entryPoint, outfile) {
// Child and worker resolvers require flat entries beside orcad.js.
function buildIsolatedEntry(entryPoint, outfile) {
return build({
entryPoints: [entryPoint],
bundle: true,
Expand All @@ -120,9 +119,15 @@ function buildForkedChild(entryPoint, outfile) {
})
}

const childResults = await Promise.all([
buildForkedChild(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildForkedChild(DAEMON_ENTRY, DAEMON_OUT_FILE)
const isolatedResults = await Promise.all([
buildIsolatedEntry(WATCHER_ENTRY, WATCHER_OUT_FILE),
buildIsolatedEntry(DAEMON_ENTRY, DAEMON_OUT_FILE),
...['writer', 'backup'].map((role) =>
buildIsolatedEntry(
join(ROOT, `src/main/persistence/profile-state/profile-state-${role}-worker-entry.ts`),
join(OUT_DIR, `profile-state-${role}-worker-entry.js`)
)
)
])

const result = await build({
Expand All @@ -147,9 +152,7 @@ const output = Object.values(result.metafile.outputs).find(
// Why check `original` and not just `path`: when electron is bundleable, esbuild
// rewrites `path` to the resolved file under node_modules and the naive check passes
// while the package is very much in the bundle.
// Why both metafiles: the forked children ship in the same deployment and run under the
// same plain Node. A daemon-entry that reached electron would fail at fork time, on the
// path whose whole point is that terminals survive.
// Every isolated entry ships under the same plain-Node compatibility contract.
function collectImporters(metafiles, matches) {
const importers = new Set()
for (const metafile of metafiles) {
Expand All @@ -164,7 +167,7 @@ function collectImporters(metafiles, matches) {
return importers
}

const metafiles = [result.metafile, ...childResults.map((child) => child.metafile)]
const metafiles = [result.metafile, ...isolatedResults.map((entry) => entry.metafile)]
const electronImporters = collectImporters(
metafiles,
(specifier) => specifier === 'electron' || specifier.startsWith('electron/')
Expand Down Expand Up @@ -254,6 +257,12 @@ if (graphErrors.length > 0) {
)
process.exitCode = 1
}
try {
await smokeProfileStateWorkers(OUT_DIR)
} catch (error) {
console.error('[build-orcad] profile state worker check failed:', error)
process.exitCode = 1
}
}

// Why a content hash and not ORCAD_VERSION alone: the remote install directory is keyed on
Expand Down Expand Up @@ -295,7 +304,10 @@ async function smokeLoadWatcherChild() {
resolve(failure)
}
child.on('message', (message) => {
if (message?.op === 'subscribe-started') {
// Wait until the subscribe lifecycle has sent its final acknowledgement.
// Disconnecting on subscribe-started races the subsequent subscribed or
// subscribe-failed message and makes the child report an expected EPIPE.
if (message?.op === 'subscribed' || message?.op === 'subscribe-failed') {
child.disconnect()
}
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { readFileSync, readdirSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
import { GUARDED_ENTRY_NAMES } from '../build-plugins/plain-node-entry-guard'

const REPO_ROOT = resolve(__dirname, '..', '..')
const CLI_ROOT = join(REPO_ROOT, 'src', 'cli')
Expand All @@ -18,7 +20,7 @@ function listCliSourceFiles(dir: string): string[] {
}

// Why: `import type` is erased by tsc, so it needs no emitted module at runtime.
const VALUE_IMPORT_FROM_MAIN = /(?<!\btype\s)from '\.\.\/\.\.\/main\/([^']+)'/g
const VALUE_IMPORT_FROM_MAIN = /(?<!\btype\s)from '(?:\.\.\/)+main\/([^']+)'/g

function findMainImports(): { file: string; module: string }[] {
return listCliSourceFiles(CLI_ROOT).flatMap((file) => {
Expand All @@ -30,12 +32,12 @@ function findMainImports(): { file: string; module: string }[] {
})
}

function findElectronViteMainEntries(): Set<string> {
const config = readFileSync(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf-8')
return new Set(
// Why: entries wrap across lines once the path is long, so allow whitespace.
[...config.matchAll(/resolve\(\s*'src\/main\/([^']+)\.ts'\s*\)/g)].map((match) => match[1])
)
function findElectronViteMainEntries(): Record<string, string> {
const input = electronViteConfig.main?.build?.rollupOptions?.input
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Expected named main-process inputs')
}
return input
}

describe('CLI imports of main-process modules', () => {
Expand All @@ -45,14 +47,25 @@ describe('CLI imports of main-process modules', () => {
// final-artifact runtime verifier.
it('has an electron-vite entry for every main module the CLI imports', () => {
const entries = findElectronViteMainEntries()
const missing = findMainImports().filter(({ module }) => !entries.has(module))
const missing = findMainImports().filter(
({ module }) => entries[module] !== join(REPO_ROOT, 'src', 'main', `${module}.ts`)
)

expect(missing).toEqual([])
})

it('guards every CLI main module against Electron imports', () => {
const guarded = new Set<string>(GUARDED_ENTRY_NAMES)
expect(findMainImports().filter(({ module }) => !guarded.has(module))).toEqual([])
})

it('finds the imports it is meant to guard', () => {
// Why: a broken matcher would make the guard above vacuously pass.
expect(findMainImports()).toContainEqual({
file: join('src', 'cli', 'profile-state-location.ts'),
module: 'persistence/profile-state/profile-state-active-location'
})
expect(findMainImports().length).toBeGreaterThanOrEqual(2)
expect(findElectronViteMainEntries().size).toBeGreaterThanOrEqual(2)
expect(Object.keys(findElectronViteMainEntries()).length).toBeGreaterThanOrEqual(2)
})
})
31 changes: 28 additions & 3 deletions config/scripts/electron-vite-output-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ function failBootstrapWithBanner(options: {
return processMock
}

const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs') as {
files: string[]
}
const electronBuilderConfig = createRequire(import.meta.url)('../electron-builder.config.cjs')

describe('Electron Vite output contract', () => {
it("minifies main and renderer with rolldown's in-process minifier", () => {
Expand Down Expand Up @@ -104,6 +102,33 @@ describe('Electron Vite output contract', () => {
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
})

it('keeps offline profile-state CLI imports unpacked at stable paths', () => {
const input = electronViteConfig.main?.build?.rollupOptions?.input
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new Error('Expected named main-process inputs')
}

for (const name of [
'persistence/profile-state/profile-state-access',
'persistence/profile-state/profile-state-active-location',
'persistence/profile-state/profile-state-backup-path',
'persistence/profile-state/profile-state-database-recovery',
'persistence/profile-state/profile-state-domain-reader',
'persistence/profile-state/profile-state-export-path',
'persistence/profile-state/profile-state-offline-settings',
'persistence/profile-state/profile-state-recovery',
'persistence/profile-state/profile-state-recovery-command',
'persistence/profile-state/profile-state-storage-classification',
'startup/http1-compatibility-marker'
]) {
expect(input).toHaveProperty(name)
}
expect(electronBuilderConfig.asarUnpack).toContain('out/main/persistence/profile-state/**')
expect(electronBuilderConfig.asarUnpack).toContain(
'out/main/startup/http1-compatibility-marker.js'
)
})

it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
const external = electronViteConfig.main?.build?.rollupOptions?.external
if (typeof external !== 'function') {
Expand Down
60 changes: 41 additions & 19 deletions config/scripts/plain-node-entry-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import {
CLI_MAIN_ENTRY_NAMES,
createPlainNodeEntryGuardPlugin,
GUARDED_ENTRY_NAMES
} from '../build-plugins/plain-node-entry-guard'
Expand Down Expand Up @@ -158,8 +159,8 @@ describe('guarded entry names', () => {
// main-process worker and kills it at startup. The worker entries carried only
// hand-written "must stay electron-free" comments, and the port-scan worker sits
// one import away from a client that deliberately does require electron.
describe('worker thread entry guard', () => {
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
describe('CLI and worker thread entry guard', () => {
function runEntryWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
Expand All @@ -171,7 +172,7 @@ describe('worker thread entry guard', () => {
)
}

function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
function entryChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
Expand All @@ -183,33 +184,54 @@ describe('worker thread entry guard', () => {
} as Rollup.OutputChunk
}

it.each(CLI_MAIN_ENTRY_NAMES)('rejects direct and transitive Electron imports in %s', (name) => {
const plugin = createPlainNodeEntryGuardPlugin()
const entry = entryChunk(name, 'require("electron")')
const bundle: Rollup.OutputBundle = { [entry.fileName]: entry }
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')

entry.code = ''
const shared = entryChunk('shared', 'require("electron/main")')
shared.isEntry = false
bundle[shared.fileName] = shared
for (const edge of ['imports', 'dynamicImports'] as const) {
entry[edge] = [shared.fileName]
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')
entry[edge] = []
}

shared.code = 'require("node:fs")'
entry.imports = [shared.fileName]
expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow()
})

it('rejects an Electron require reachable from a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'port-scan-command-worker-entry.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'port-scan-command-worker-entry.js': entryChunk(
'port-scan-command-worker-entry',
'require("electron")'
)
} as Rollup.OutputBundle
}

expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('requires electron')
})

it('names the worker-thread runtime so the failure is actionable', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
} as Rollup.OutputBundle
const bundle: Rollup.OutputBundle = {
'stt-worker.js': entryChunk('stt-worker', 'require("electron")')
}

expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
})

// The real risk is transitive: a worker entry importing a shared chunk that
// reaches the electron-requiring client, not a direct import anyone would spot.
it('follows shared chunks out of a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'session-scanner-opencode-sqlite-worker-entry.js': entryChunk(
'session-scanner-opencode-sqlite-worker-entry',
'require("./chunks/shared.js")',
['chunks/shared.js']
Expand All @@ -223,20 +245,20 @@ describe('worker thread entry guard', () => {
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
} as Rollup.OutputBundle
}

expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
expect(() => runEntryWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
})

it('passes a clean worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'warp-theme-parser-worker.js': workerChunk(
const bundle: Rollup.OutputBundle = {
'warp-theme-parser-worker.js': entryChunk(
'warp-theme-parser-worker',
'require("node:worker_threads")'
)
} as Rollup.OutputBundle
}

expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
expect(() => runEntryWriteBundle(plugin, bundle)).not.toThrow()
})
})
Loading
Loading