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
2 changes: 2 additions & 0 deletions src/main/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { join } from "node:path";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import * as schema from "../db.schema";
import { resetMainCreatedThreads } from "./mainCreatedThreads";
import {
assertRequiredDatabaseSchema,
repairSafeSchemaDrift,
Expand Down Expand Up @@ -316,4 +317,5 @@ export function closeDatabase() {
}
_sqlite = undefined;
_db = undefined;
resetMainCreatedThreads();
}
36 changes: 36 additions & 0 deletions src/main/db/mainCreatedThreads.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Thread rows main inserted itself — remote `start` commands, schedules,
* orchestrator launches — that the renderer's store has not mirrored yet.
*
* The renderer persists its whole store through `dbSyncAll`, which deletes every
* thread row missing from that snapshot. A main-created row is missing from it
* until the forwarded `start` command reaches the renderer, and the delete
* cascades into `thread_runtime_items`: the launch turn's `user_message` (already
* persisted from the supervisor's emit) disappears, and later events are dropped
* until the renderer re-creates the row. Ids tracked here are exempt from that
* delete; the first renderer snapshot carrying an id hands ownership back to the
* renderer, so its own deletions keep working.
*/
const unmirroredThreadIds = new Set<string>();

export function noteMainCreatedThread(threadId: string): void {
unmirroredThreadIds.add(threadId);
}

export function forgetMainCreatedThread(threadId: string): void {
unmirroredThreadIds.delete(threadId);
}

export function isMainCreatedThreadUnmirrored(threadId: string): boolean {
return unmirroredThreadIds.has(threadId);
}

/** A renderer snapshot arrived: every thread it carries is renderer-owned now. */
export function acknowledgeMirroredThreadIds(threadIds: Iterable<string>): void {
for (const threadId of threadIds) unmirroredThreadIds.delete(threadId);
}

/** Tied to the open database — a new database starts with no pending rows. */
export function resetMainCreatedThreads(): void {
unmirroredThreadIds.clear();
}
12 changes: 12 additions & 0 deletions src/main/db/projectsThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { asc, eq, notInArray } from "drizzle-orm";
import type { Project, Thread } from "@/shared/contracts";
import * as schema from "../db.schema";
import { getDb } from "./connection";
import { forgetMainCreatedThread, noteMainCreatedThread } from "./mainCreatedThreads";
import { notifyProjectThreadDataChanged } from "./projectThreadChanges";
import { projectMutableRow, rowToProject, rowToThread } from "./rowMappers";

Expand Down Expand Up @@ -84,6 +85,15 @@ export function dbUpdateProject(project: Project): void {

export function dbUpsertThread(thread: Thread, sortOrder: number): void {
const db = getDb();
// A row main inserts on its own is invisible to the renderer's store until the
// forwarded command reaches it, so shield it from `dbSyncAll`'s delete pass
// (see mainCreatedThreads).
const isNewRow =
db
.select({ id: schema.threads.id })
.from(schema.threads)
.where(eq(schema.threads.id, thread.id))
.get() === undefined;
db.insert(schema.threads)
.values({
id: thread.id,
Expand Down Expand Up @@ -147,6 +157,7 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void {
},
})
.run();
if (isNewRow) noteMainCreatedThread(thread.id);
notifyProjectThreadDataChanged();
}

Expand Down Expand Up @@ -182,6 +193,7 @@ export function dbMarkLiveThreadsInactive(): void {
export function dbDeleteThread(threadId: string): void {
const db = getDb();
db.delete(schema.threads).where(eq(schema.threads.id, threadId)).run();
forgetMainCreatedThread(threadId);
notifyProjectThreadDataChanged();
}

Expand Down
110 changes: 110 additions & 0 deletions src/main/db/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Project, Thread } from "@/shared/contracts";
import { closeDatabase, initDatabase } from "./connection";
import { dbGetThread, dbUpsertProject, dbUpsertThread } from "./projectsThreads";
import { dbApplyThreadRuntimeEvents, dbGetThreadRuntimeItems } from "./runtimeItems";
import { dbSyncAll } from "./sync";

const serverNativeBinding = join(process.cwd(), "dist", "server-native", "better_sqlite3.node");
let nativeBindingEnv: string | undefined;
let sqliteAvailable = true;
try {
new Database(":memory:").close();
} catch {
if (existsSync(serverNativeBinding)) {
nativeBindingEnv = serverNativeBinding;
} else {
sqliteAvailable = false;
}
}

const project: Project = {
id: "project-1",
name: "Test project",
location: { kind: "posix", path: "/tmp/project" },
createdAt: "2026-01-01T00:00:00.000Z",
};

function remoteStartedThread(): Thread {
return {
id: "thread-remote",
projectId: project.id,
title: "Started from a remote client",
agentKind: "claude",
config: { model: "claude-opus-5" },
status: "launching",
attention: "none",
canResumeWithConfig: false,
archived: false,
done: false,
starred: false,
presentationMode: "gui",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}

function persistLaunchUserMessage(threadId: string): void {
dbApplyThreadRuntimeEvents(threadId, [
{ type: "turn.started", threadId, turnId: "turn-1" },
{
type: "item.started",
threadId,
itemId: "user-1",
itemType: "user_message",
payload: { content: [{ kind: "text", text: "fix the sidebar" }] },
},
{ type: "item.completed", threadId, itemId: "user-1" },
]);
}

describe.skipIf(!sqliteAvailable)("dbSyncAll thread ownership", () => {
let dir: string;

beforeEach(() => {
if (nativeBindingEnv) {
process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING = nativeBindingEnv;
}
dir = mkdtempSync(join(tmpdir(), "poracode-sync-db-test-"));
initDatabase(join(dir, "state.sqlite"));
dbUpsertProject(project, 0);
});

afterEach(() => {
closeDatabase();
rmSync(dir, { recursive: true, force: true });
delete process.env.PORACODE_BETTER_SQLITE3_NATIVE_BINDING;
});

it("keeps a main-created thread (and its launch transcript) that the renderer has not mirrored yet", () => {
dbUpsertThread(remoteStartedThread(), 0);
persistLaunchUserMessage("thread-remote");

// Renderer flushes its store before the forwarded `start` command lands.
dbSyncAll([project], [], JSON.stringify({ kind: "home" }));

expect(dbGetThread("thread-remote")).not.toBeNull();
expect(dbGetThreadRuntimeItems("thread-remote").map((item) => item.type)).toEqual([
"user_message",
]);
});

it("still deletes a thread the renderer dropped after it had mirrored it", () => {
dbUpsertThread(remoteStartedThread(), 0);
persistLaunchUserMessage("thread-remote");

// Renderer applied the command: its snapshot now carries the thread.
dbSyncAll([project], [remoteStartedThread()], JSON.stringify({ kind: "home" }));
expect(dbGetThreadRuntimeItems("thread-remote")).toHaveLength(1);

// The user deletes it in the renderer.
dbSyncAll([project], [], JSON.stringify({ kind: "home" }));

expect(dbGetThread("thread-remote")).toBeNull();
expect(dbGetThreadRuntimeItems("thread-remote")).toEqual([]);
});
});
14 changes: 11 additions & 3 deletions src/main/db/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "@/shared/contracts";
import type { DbPersistExperimentStatePayload } from "@/shared/ipc";
import { getSqlite } from "./connection";
import { acknowledgeMirroredThreadIds, isMainCreatedThreadUnmirrored } from "./mainCreatedThreads";
import { notifyProjectThreadDataChanged } from "./projectThreadChanges";
import { projectMutableRow } from "./rowMappers";

Expand Down Expand Up @@ -44,13 +45,20 @@ export function dbSyncAll(projectsData: Project[], threadsData: Thread[], viewJs
const upsertThread = prepareThreadSyncStatement(sqlite);

for (const tid of existingThreadIds) {
if (!incomingThreadIds.has(tid)) {
deleteThread.run(tid);
}
if (incomingThreadIds.has(tid)) continue;
// A thread main just created (remote `start`, schedule, orchestrator) is
// absent from this snapshot only because the renderer has not applied the
// forwarded command yet. Deleting it would cascade away the launch turn's
// runtime items — most visibly the initial `user_message`.
if (isMainCreatedThreadUnmirrored(tid)) continue;
deleteThread.run(tid);
}
for (let i = 0; i < threadsData.length; i++) {
runThreadSync(upsertThread, threadsData[i]!, i);
}
// Anything in this snapshot is renderer-owned from here on, so a later
// snapshot that drops it is a real deletion.
acknowledgeMirroredThreadIds(incomingThreadIds);

sqlite
.prepare(
Expand Down