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
3 changes: 2 additions & 1 deletion src/main/app-controls/mcp/toolRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,7 @@ describe("Poracode app control tools — threads", () => {

it("update_thread dispatches the matching remote thread commands", async () => {
const threads = [makeThread({ id: "a" })];
const { ctx, emitRemoteThreadCommand } = context({ threads });
const { ctx, emitRemoteThreadCommand, updatedRows } = context({ threads });
const result = (await dispatchTool(
"update_thread",
{ threadId: "a", rename: "Renamed", done: true, archived: true },
Expand All @@ -612,6 +612,7 @@ describe("Poracode app control tools — threads", () => {
done: true,
});
expect(emitRemoteThreadCommand).toHaveBeenCalledWith({ kind: "archive", threadId: "a" });
expect(updatedRows.at(-1)).toMatchObject({ archived: true, archivedAt: expect.any(String) });
});

it("send_to_thread interrupts first when requested then sends", async () => {
Expand Down
10 changes: 9 additions & 1 deletion src/main/app-controls/mcp/tools/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,15 @@ export const threadTools: ToolDomain = {
}));
applyField(parsed.archived, "archived", (archived) => ({
command: { kind: archived ? "archive" : "unarchive", threadId },
mutate: (thread) => ({ ...thread, archived, updatedAt: stamp() }),
mutate: (thread) => {
const now = stamp();
return {
...thread,
archived,
archivedAt: archived ? now : undefined,
updatedAt: now,
};
},
}));
if (parsed.acknowledge) {
const command: RemoteThreadCommand = { kind: "acknowledge", threadId };
Expand Down
1 change: 1 addition & 0 deletions src/main/db.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const threads = sqliteTable("threads", {
/** Orchestrator thread that created this one via the Crossagents MCP. */
parentThreadId: text("parent_thread_id"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
archivedAt: text("archived_at"),
done: integer("done", { mode: "boolean" }).notNull().default(false),
doneAt: text("done_at"),
starred: integer("starred", { mode: "boolean" }).notNull().default(false),
Expand Down
1 change: 1 addition & 0 deletions src/main/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export function initDatabase(dbPath: string) {
worktree_branch TEXT,
pr_number INTEGER,
archived INTEGER NOT NULL DEFAULT 0,
archived_at TEXT,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
Expand Down
3 changes: 2 additions & 1 deletion src/main/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ describe("database migration registry", () => {
[32, "pr watch blocked reason"],
[33, "project GitHub account"],
[34, "projects.icon"],
[35, "threads.archived_at"],
]);
expect(LATEST_SCHEMA_VERSION).toBe(34);
expect(LATEST_SCHEMA_VERSION).toBe(35);
expect(() => validateMigrationRegistry()).not.toThrow();
});

Expand Down
15 changes: 15 additions & 0 deletions src/main/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,16 @@ export const DATABASE_MIGRATIONS = [
name: "projects.icon",
migrate: (sqlite) => addColumnIfMissing(sqlite, "projects", "icon", "TEXT"),
},
{
version: 35,
name: "threads.archived_at",
migrate: (sqlite) => {
addColumnIfMissing(sqlite, "threads", "archived_at", "TEXT");
sqlite.exec(
"UPDATE threads SET archived_at = updated_at WHERE archived = 1 AND archived_at IS NULL",
);
},
},
] as const satisfies readonly DatabaseMigration[];

export const LATEST_SCHEMA_VERSION = DATABASE_MIGRATIONS[DATABASE_MIGRATIONS.length - 1]!.version;
Expand Down Expand Up @@ -452,6 +462,7 @@ const SAFE_COLUMN_REPAIRS = [
["threads", "agent_instance_id", "TEXT"],
["threads", "thread_status_source", "TEXT"],
["threads", "parent_thread_id", "TEXT"],
["threads", "archived_at", "TEXT"],
["threads", "active_turn_started_at", "TEXT"],
["threads", "last_turn_started_at", "TEXT"],
["threads", "last_turn_ended_at", "TEXT"],
Expand All @@ -470,6 +481,9 @@ export function repairSafeSchemaDrift(sqlite: SqliteDatabase): void {
for (const [table, column, definition] of SAFE_COLUMN_REPAIRS) {
addColumnIfMissing(sqlite, table, column, definition);
}
sqlite.exec(
"UPDATE threads SET archived_at = updated_at WHERE archived = 1 AND archived_at IS NULL",
);
})();
}

Expand Down Expand Up @@ -514,6 +528,7 @@ const REQUIRED_COLUMNS = {
"group_name",
"parent_thread_id",
"archived",
"archived_at",
"done",
"done_at",
"starred",
Expand Down
113 changes: 112 additions & 1 deletion src/main/db/projectsThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,37 @@ describe("projectsThreads (real sqlite round-trip)", () => {
expect(dbGetThread("thread-1")?.threadStatusSource).toBeUndefined();
});

it("round-trips and clears the thread archive timestamp", () => {
dbUpsertThread(
testThread({
archived: true,
archivedAt: "2026-02-02T03:04:05.000Z",
}),
0,
);
expect(dbGetThread("thread-1")?.archivedAt).toBe("2026-02-02T03:04:05.000Z");

dbUpsertThread(testThread(), 0);
expect(dbGetThread("thread-1")?.archivedAt).toBeUndefined();
});

it("backfills archive timestamps when upgrading schema v34", () => {
dbUpsertThread(
testThread({
archived: true,
updatedAt: "2026-02-03T04:05:06.000Z",
}),
0,
);
dbSetState("schema_version", "34");

closeDatabase();
initDatabase(join(dir, "state.sqlite"));

expect(dbGetThread("thread-1")?.archivedAt).toBe("2026-02-03T04:05:06.000Z");
expect(dbGetState("schema_version")).toBe(String(LATEST_SCHEMA_VERSION));
});

it("round-trips project MCP servers through the projects table", () => {
dbUpsertProject(
{
Expand Down Expand Up @@ -263,7 +294,7 @@ describe("projectsThreads (real sqlite round-trip)", () => {

initDatabase(databasePath);

expect(dbGetState("schema_version")).toBe("34");
expect(dbGetState("schema_version")).toBe("35");
const legacyProject = dbGetProject("legacy-project");
expect(legacyProject).toMatchObject({
id: "legacy-project",
Expand Down Expand Up @@ -372,6 +403,86 @@ describe("projectsThreads (real sqlite round-trip)", () => {
});
});

it("repairs and backfills archived_at when schema v35 is missing the column", () => {
closeDatabase();
const databasePath = join(dir, "corrupt-v35.sqlite");
const corrupt = nativeBindingEnv
? new Database(databasePath, { nativeBinding: nativeBindingEnv })
: new Database(databasePath);
corrupt.exec(`
CREATE TABLE projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
location_kind TEXT NOT NULL,
location_path TEXT,
location_distro TEXT,
location_linux_path TEXT,
location_unc_path TEXT,
last_draft_config TEXT,
scripts TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE threads (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL,
agent_kind TEXT NOT NULL,
agent_instance_id TEXT,
config TEXT NOT NULL,
status TEXT NOT NULL,
attention TEXT NOT NULL,
thread_status_source TEXT,
can_resume_with_config INTEGER NOT NULL DEFAULT 0,
session_ref TEXT,
terminal_prompt TEXT,
worktree_path TEXT,
worktree_branch TEXT,
pr_number INTEGER,
group_id TEXT,
group_name TEXT,
parent_thread_id TEXT,
archived INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
starred INTEGER NOT NULL DEFAULT 0,
presentation_mode TEXT NOT NULL DEFAULT 'terminal',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
active_turn_started_at TEXT,
last_turn_started_at TEXT,
last_turn_ended_at TEXT
);
CREATE TABLE app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT INTO projects (
id, name, location_kind, location_path, sort_order, created_at
) VALUES (
'legacy-project', 'Legacy project', 'posix', '/tmp/legacy-project', 0,
'2026-01-01T00:00:00.000Z'
);
INSERT INTO threads (
id, project_id, title, agent_kind, config, status, attention,
can_resume_with_config, archived, done, starred, presentation_mode,
sort_order, created_at, updated_at
) VALUES (
'legacy-archived', 'legacy-project', 'Legacy archived', 'claude',
'{"model":"sonnet"}', 'inactive', 'none', 0, 1, 0, 0, 'gui', 0,
'2026-01-01T00:00:00.000Z', '2026-02-03T04:05:06.000Z'
);
INSERT INTO app_state (key, value) VALUES ('schema_version', '35');
`);
corrupt.close();

initDatabase(databasePath);

expect(dbGetThread("legacy-archived")?.archivedAt).toBe("2026-02-03T04:05:06.000Z");
expect(dbGetState("schema_version")).toBe("35");
});

it("repairs blank legacy thread models from schema v29 without changing valid configs", () => {
const sqlite = getSqlite();
const insert = sqlite.prepare(`
Expand Down
2 changes: 2 additions & 0 deletions src/main/db/projectsThreads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void {
groupName: thread.groupName ?? null,
parentThreadId: thread.parentThreadId ?? null,
archived: thread.archived,
archivedAt: thread.archivedAt ?? null,
done: thread.done,
doneAt: thread.doneAt ?? null,
starred: thread.starred,
Expand Down Expand Up @@ -145,6 +146,7 @@ export function dbUpsertThread(thread: Thread, sortOrder: number): void {
groupName: thread.groupName ?? null,
parentThreadId: thread.parentThreadId ?? null,
archived: thread.archived,
archivedAt: thread.archivedAt ?? null,
done: thread.done,
doneAt: thread.doneAt ?? null,
starred: thread.starred,
Expand Down
1 change: 1 addition & 0 deletions src/main/db/rowMappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function rowToThread(row: typeof schema.threads.$inferSelect): Thread {
...(row.groupName ? { groupName: row.groupName } : {}),
...(row.parentThreadId ? { parentThreadId: row.parentThreadId } : {}),
archived: row.archived,
...(row.archivedAt ? { archivedAt: row.archivedAt } : {}),
done: row.done,
...(row.doneAt ? { doneAt: row.doneAt } : {}),
starred: row.starred,
Expand Down
6 changes: 4 additions & 2 deletions src/main/db/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,13 @@ function prepareThreadSyncStatement(sqlite: InstanceType<typeof Database>): Sqli
INSERT INTO threads (
id, project_id, title, agent_kind, agent_instance_id, config, status,
attention, can_resume_with_config, session_ref, terminal_prompt, worktree_path,
worktree_branch, pr_number, group_id, group_name, parent_thread_id, archived, done, done_at,
worktree_branch, pr_number, group_id, group_name, parent_thread_id, archived, archived_at, done, done_at,
starred, presentation_mode, sort_order, created_at, updated_at,
active_turn_started_at, last_turn_started_at, last_turn_ended_at
) VALUES (
@id, @projectId, @title, @agentKind, @agentInstanceId, @config, @status,
@attention, @canResumeWithConfig, @sessionRef, NULL, @worktreePath,
@worktreeBranch, @prNumber, @groupId, @groupName, @parentThreadId, @archived, @done, @doneAt,
@worktreeBranch, @prNumber, @groupId, @groupName, @parentThreadId, @archived, @archivedAt, @done, @doneAt,
@starred, @presentationMode, @sortOrder, @createdAt, @updatedAt,
@activeTurnStartedAt, @lastTurnStartedAt, @lastTurnEndedAt
)
Expand All @@ -170,6 +170,7 @@ function prepareThreadSyncStatement(sqlite: InstanceType<typeof Database>): Sqli
group_name = excluded.group_name,
parent_thread_id = excluded.parent_thread_id,
archived = excluded.archived,
archived_at = excluded.archived_at,
done = excluded.done,
done_at = excluded.done_at,
starred = excluded.starred,
Expand Down Expand Up @@ -201,6 +202,7 @@ function runThreadSync(stmt: SqliteStatement, thread: Thread, sortOrder: number)
groupName: thread.groupName ?? null,
parentThreadId: thread.parentThreadId ?? null,
archived: thread.archived ? 1 : 0,
archivedAt: thread.archivedAt ?? null,
done: thread.done ? 1 : 0,
doneAt: thread.doneAt ?? null,
starred: thread.starred ? 1 : 0,
Expand Down
Loading