diff --git a/src/apply-patch.ts b/src/apply-patch.ts index 05a73de6..eb688798 100644 --- a/src/apply-patch.ts +++ b/src/apply-patch.ts @@ -440,7 +440,7 @@ async function writeTextFile(destination: string, content: string, mode?: number } } -function unifiedFilePatch( +export function unifiedFilePatch( oldPath: string, newPath: string, oldContent: string | null, diff --git a/src/change-journal.test.ts b/src/change-journal.test.ts new file mode 100644 index 00000000..3f6c9091 --- /dev/null +++ b/src/change-journal.test.ts @@ -0,0 +1,278 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import { workspaceSessions } from "./db/schema.js"; +import { + createChangeJournalManager, + MAX_JOURNAL_DIFF_BYTES, + type ChangeJournalManager, +} from "./change-journal.js"; + +interface JournalFixture { + root: string; + stateDir: string; + database: DatabaseHandle; + journal: ChangeJournalManager; + project: string; +} + +async function fixture(t: TestContext): Promise { + const root = await mkdtemp(join(tmpdir(), "devspace-journal-test-")); + const project = join(root, "project"); + const stateDir = join(root, "state"); + await mkdir(project, { recursive: true }); + const database = openDatabase(stateDir); + const journal = createChangeJournalManager(database.db); + await database.db.insert(workspaceSessions).values({ + id: "w1", + root: project, + status: "active", + mode: "checkout", + managed: "false", + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString(), + }); + t.after(async () => { + database.close(); + await rm(root, { recursive: true, force: true }); + }); + return { root, stateDir, database, journal, project }; +} + +async function write(project: string, path: string, content: string): Promise { + await writeFile(join(project, path), content); +} + +test("creates a net diff across multiple touches of one file", async (t) => { + const { journal, project } = await fixture(t); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "alpha\n"); + await write(project, "notes.txt", "alpha\nbeta\n"); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + + assert.equal(review.result, "Changed 1 file (+2 -0)."); + assert.equal(review.files.length, 1); + assert.equal(review.files[0]?.type, "new"); + assert.equal(review.files[0]?.additions, 2); + assert.match(review.patch, /a\/notes.txt/); +}); + +test("the first touch captures the original, later edits diff against it", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "notes.txt", "alpha\n"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "alpha\nbeta\n"); + await write(project, "notes.txt", "alpha\nbeta\ngamma\n"); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + + assert.equal(review.files[0]?.type, "change"); + assert.equal(review.summary.additions, 2); + assert.doesNotMatch(review.patch, /\/dev\/null/); + assert.match(review.patch, /\+beta/); +}); + +test("edits that revert to the original produce no changes and drop the row", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "notes.txt", "alpha\n"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "alpha\nbeta\n"); + + assert.equal( + (await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false })).files.length, + 1, + ); + + await write(project, "notes.txt", "alpha\n"); + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: true }); + + assert.equal(review.result, "No changes since last shown changes."); + assert.equal(review.files.length, 0); + + const again = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: true }); + assert.equal(again.files.length, 0); +}); + +test("a new file created then deleted is net zero", async (t) => { + const { journal, project } = await fixture(t); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "scratch.txt" }); + await write(project, "scratch.txt", "content\n"); + await rm(join(project, "scratch.txt")); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: true }); + assert.equal(review.result, "No changes since last shown changes."); + assert.equal(review.files.length, 0); +}); + +test("an existing file deleted after a touch reports a deletion", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "notes.txt", "alpha\n"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "alpha\nbeta\n"); + await rm(join(project, "notes.txt")); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files[0]?.type, "deleted"); + assert.equal(review.summary.removals, 1); +}); + +test("moves report the previous path and re-baseline clears it", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "old.txt", "alpha\nbeta\n"); + + await journal.recordTouch({ + workspaceId: "w1", + root: project, + path: "new.txt", + previousPath: "old.txt", + }); + await write(project, "new.txt", "alpha\nbeta\n"); + await rm(join(project, "old.txt")); + + const first = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: true }); + assert.equal(first.files[0]?.type, "rename-pure"); + assert.equal(first.files[0]?.previousPath, "old.txt"); + assert.equal(first.summary.additions, 0); + + await write(project, "new.txt", "alpha\nbeta\ngamma\n"); + const second = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(second.files[0]?.type, "change"); + assert.equal(second.files[0]?.previousPath, undefined); + assert.equal(second.summary.additions, 1); +}); + +test("binary filenames appear in the review without a text patch", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "logo.png", "PNG\x00\x01\x02"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "logo.png" }); + await write(project, "logo.png", "PNG\x00\x03\x04"); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files.length, 1); + assert.equal(review.files[0]?.path, "logo.png"); + assert.equal(review.patch, ""); +}); + +test("content above the diff size cap degrades to a file list", async (t) => { + const { journal, project } = await fixture(t); + const big = "x".repeat(MAX_JOURNAL_DIFF_BYTES); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "big.txt" }); + await write(project, "big.txt", `${big}\n`); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files.length, 1); + assert.equal(review.patch, ""); + assert.equal(review.summary.additions, 0); +}); + +test("first-touch wins when the same path is touched repeatedly", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "notes.txt", "first\n"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "second\n"); + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.match(review.patch, /-first/); +}); + +test("the journal survives a database restart", async (t) => { + const { database, stateDir, journal, project } = await fixture(t); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "notes.txt" }); + await write(project, "notes.txt", "alpha\n"); + + database.close(); + const reopened = openDatabase(stateDir); + const restarted = createChangeJournalManager(reopened.db); + t.after(() => reopened.close()); + + const review = await restarted.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files.length, 1); + assert.equal(review.files[0]?.type, "new"); +}); + +test("a touched binary file deleted before review reports a deletion", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "logo.png", "PNG\x00\x01\x02"); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "logo.png" }); + await rm(join(project, "logo.png")); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: true }); + assert.equal(review.files.length, 1); + assert.equal(review.files[0]?.path, "logo.png"); + assert.equal(review.files[0]?.type, "deleted"); + + const again = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(again.files.length, 0); +}); + +test("symlinks cannot smuggle content from outside the workspace into a review", async (t) => { + const { root, journal, project } = await fixture(t); + const outsideDir = join(root, "outside"); + await mkdir(outsideDir); + await writeFile(join(outsideDir, "secret.txt"), "external secrets\n"); + + await symlink(join(outsideDir, "secret.txt"), join(project, "link.txt")); + await assert.rejects( + journal.recordTouch({ workspaceId: "w1", root: project, path: "link.txt" }), + /outside workspace root/, + ); + + await write(project, "plain.txt", "alpha\n"); + await journal.recordTouch({ workspaceId: "w1", root: project, path: "plain.txt" }); + await rm(join(project, "plain.txt")); + await symlink(join(outsideDir, "secret.txt"), join(project, "plain.txt")); + await assert.rejects( + journal.reviewChanges({ workspaceId: "w1", root: project }), + /outside workspace root/, + ); +}); + +test("a symlink to a file inside the workspace is reviewable", async (t) => { + const { journal, project } = await fixture(t); + await write(project, "real.txt", "alpha\n"); + await symlink(join(project, "real.txt"), join(project, "alias.txt")); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "alias.txt" }); + await write(project, "real.txt", "alpha\nbeta\n"); + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files.length, 1); + assert.equal(review.files[0]?.path, "alias.txt"); + assert.equal(review.summary.additions, 1); +}); + +test("the diff cap counts bytes, not UTF-16 code units", async (t) => { + const { journal, project } = await fixture(t); + const chunk = "é".repeat(Math.ceil(MAX_JOURNAL_DIFF_BYTES / 2)); + + await journal.recordTouch({ workspaceId: "w1", root: project, path: "uni.txt" }); + await write(project, "uni.txt", `${chunk}\n`); + + const review = await journal.reviewChanges({ workspaceId: "w1", root: project, markReviewed: false }); + assert.equal(review.files.length, 1); + assert.equal(review.patch, ""); + assert.equal(review.summary.additions, 0); +}); + +test("path containment is enforced", async (t) => { + const { journal, project } = await fixture(t); + + await assert.rejects( + journal.recordTouch({ workspaceId: "w1", root: project, path: "../escape.txt" }), + /outside workspace root/, + ); +}); \ No newline at end of file diff --git a/src/change-journal.ts b/src/change-journal.ts new file mode 100644 index 00000000..6ebd1ecb --- /dev/null +++ b/src/change-journal.ts @@ -0,0 +1,286 @@ +import { readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { and, eq } from "drizzle-orm"; +import { unifiedFilePatch } from "./apply-patch.js"; +import type { AppDatabase } from "./db/client.js"; +import { changeJournal } from "./db/schema.js"; +import { isPathInsideRoot } from "./roots.js"; +import type { ReviewChangesResult, ReviewFile, ReviewSummary } from "./review-checkpoints.js"; + +export const MAX_JOURNAL_DIFF_BYTES = 512 * 1024; + +export interface JournalTouchInput { + workspaceId: string; + root: string; + path: string; + previousPath?: string; +} + +export interface JournalReviewInput { + workspaceId: string; + root: string; + markReviewed?: boolean; +} + +export interface ChangeJournalManager { + recordTouch(input: JournalTouchInput): Promise; + reviewChanges(input: JournalReviewInput): Promise; +} + +interface JournalRow { + path: string; + previousPath?: string; + originalContent: string | null; + originalBinary: boolean; + isNew: boolean; + touchedAt: string; +} + +interface JournalEntry { + row: JournalRow; + currentContent: string | null; + currentBinary: boolean; +} + +type NewJournalRow = { + workspaceSessionId: string; + path: string; + previousPath?: string; + originalContent: string | null; + originalBinary: 0 | 1; + isNew: 0 | 1; + touchedAt: string; +}; + +export function createChangeJournalManager(database: AppDatabase): ChangeJournalManager { + return { + async recordTouch({ workspaceId, root, path, previousPath }) { + assertJournalPath(root, path); + if (previousPath) assertJournalPath(root, previousPath); + + const exists = await database + .select({ path: changeJournal.path }) + .from(changeJournal) + .where(and( + eq(changeJournal.workspaceSessionId, workspaceId), + eq(changeJournal.path, path), + )) + .limit(1); + if (exists.length > 0) return; + + const original = previousPath + ? await readFileIfPresent(root, previousPath) + : await readFileIfPresent(root, path); + const binary = original !== null && isBinary(original); + const row: NewJournalRow = { + workspaceSessionId: workspaceId, + path, + previousPath, + originalContent: binary ? null : original?.toString("utf8") ?? null, + originalBinary: binary ? 1 : 0, + isNew: original === null ? 1 : 0, + touchedAt: new Date().toISOString(), + }; + await database.insert(changeJournal).values(row).onConflictDoNothing(); + }, + + async reviewChanges({ workspaceId, root, markReviewed = true }) { + const rows = await loadRows(database, workspaceId); + const entries: JournalEntry[] = []; + for (const row of rows) { + assertJournalPath(root, row.path); + const current = await readFileIfPresent(root, row.path); + entries.push({ + row, + currentContent: current === null ? null : current.toString("utf8"), + currentBinary: current !== null && isBinary(current), + }); + } + + const patchParts: string[] = []; + const files: ReviewFile[] = []; + let totalAdditions = 0; + let totalRemovals = 0; + + for (const entry of entries) { + const { row, currentContent, currentBinary } = entry; + + if (!row.previousPath) { + if (currentContent === null && row.originalContent === null && !row.originalBinary) continue; + if (row.originalContent === currentContent && !row.originalBinary) continue; + } + + const originalBytes = row.originalBinary + ? 0 + : Buffer.byteLength(row.originalContent ?? ""); + const oversized = originalBytes + Buffer.byteLength(currentContent ?? "") > MAX_JOURNAL_DIFF_BYTES; + const preview = !row.originalBinary && !currentBinary && !oversized; + + let fileAdditions = 0; + let fileRemovals = 0; + if (preview) { + const filePatch = unifiedFilePatch( + row.previousPath ?? row.path, + row.path, + row.originalContent, + currentContent, + ); + const stats = countPatchLineStats(filePatch); + fileAdditions = stats.additions; + fileRemovals = stats.removals; + patchParts.push(filePatch); + } + + totalAdditions += fileAdditions; + totalRemovals += fileRemovals; + files.push({ + path: row.path, + previousPath: row.previousPath, + type: fileType( + row.path, + row.previousPath, + row.isNew, + currentContent === null, + fileAdditions, + fileRemovals, + ), + additions: fileAdditions, + removals: fileRemovals, + }); + } + + if (markReviewed) { + await rebaseline(database, workspaceId, entries); + } + + const summary = summarizeFiles(files); + return { + result: summary.files === 0 + ? "No changes since last shown changes." + : `Changed ${summary.files} ${summary.files === 1 ? "file" : "files"} (+${summary.additions} -${summary.removals}).`, + summary, + files, + patch: patchParts.join("\n"), + }; + }, + }; +} + +async function loadRows(database: AppDatabase, workspaceId: string): Promise { + const rows = await database + .select() + .from(changeJournal) + .where(eq(changeJournal.workspaceSessionId, workspaceId)) + .orderBy(changeJournal.touchedAt); + return rows.map((row) => ({ + path: row.path, + previousPath: row.previousPath ?? undefined, + originalContent: row.originalContent, + originalBinary: row.originalBinary === 1, + isNew: row.isNew === 1, + touchedAt: row.touchedAt, + })); +} + +async function rebaseline( + database: AppDatabase, + workspaceId: string, + entries: JournalEntry[], +): Promise { + for (const entry of entries) { + const { row, currentContent, currentBinary } = entry; + + if (currentContent === null) { + await database + .delete(changeJournal) + .where(and( + eq(changeJournal.workspaceSessionId, workspaceId), + eq(changeJournal.path, row.path), + )); + continue; + } + + if (!row.previousPath && row.originalContent === currentContent) { + await database + .delete(changeJournal) + .where(and( + eq(changeJournal.workspaceSessionId, workspaceId), + eq(changeJournal.path, row.path), + )); + continue; + } + + await database + .update(changeJournal) + .set({ + originalContent: currentBinary ? null : currentContent, + originalBinary: currentBinary ? 1 : 0, + isNew: 0, + previousPath: null, + touchedAt: new Date().toISOString(), + }) + .where(and( + eq(changeJournal.workspaceSessionId, workspaceId), + eq(changeJournal.path, row.path), + )); + } +} + +async function readFileIfPresent(root: string, relativePath: string): Promise { + const joined = join(root, relativePath); + try { + const resolved = await realpath(joined); + if (!isPathInsideRoot(resolved, await realpath(root))) { + throw new Error(`Path is outside workspace root: ${relativePath}`); + } + return await readFile(resolved); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function isBinary(content: Buffer): boolean { + return content.includes(0); +} + +function assertJournalPath(root: string, path: string): void { + if (!isPathInsideRoot(join(root, path), root)) { + throw new Error(`Path is outside workspace root: ${path}`); + } +} + +function countPatchLineStats(patch: string): { additions: number; removals: number } { + let additions = 0; + let removals = 0; + for (const line of patch.split("\n")) { + if (line.startsWith("+") && !line.startsWith("+++")) additions += 1; + else if (line.startsWith("-") && !line.startsWith("---")) removals += 1; + } + return { additions, removals }; +} + +function fileType( + path: string, + previousPath: string | undefined, + isNew: boolean, + isDeleted: boolean, + additions: number, + removals: number, +): ReviewFile["type"] { + if (previousPath) return additions === 0 && removals === 0 ? "rename-pure" : "rename-changed"; + if (isNew && !isDeleted) return "new"; + if (isDeleted && additions === 0) return "deleted"; + return "change"; +} + +function summarizeFiles(files: ReviewFile[]): ReviewSummary { + return files.reduce( + (summary, file) => ({ + files: summary.files + 1, + additions: summary.additions + file.additions, + removals: summary.removals + file.removals, + }), + { files: 0, additions: 0, removals: 0 }, + ); +} \ No newline at end of file diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 1c5c3298..f3a68747 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -27,6 +27,11 @@ const migrations: Migration[] = [ name: "workspace-conversation-bindings", up: migrateWorkspaceConversationBindings, }, + { + version: 5, + name: "change-journal", + up: migrateChangeJournal, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -198,6 +203,24 @@ function migrateWorkspaceConversationBindings(sqlite: Database.Database): void { `); } +function migrateChangeJournal(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists change_journal ( + workspace_session_id text not null, + path text not null, + previous_path text, + original_content text, + original_binary integer not null default 0, + is_new integer not null default 0, + touched_at text not null, + primary key (workspace_session_id, path), + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 215c6c1a..89826875 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,6 +38,24 @@ export const loadedAgentFiles = sqliteTable( ], ); +export const changeJournal = sqliteTable( + "change_journal", + { + workspaceSessionId: text("workspace_session_id") + .notNull() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + path: text("path").notNull(), + previousPath: text("previous_path"), + originalContent: text("original_content"), + originalBinary: integer("original_binary").notNull().default(0), + isNew: integer("is_new").notNull().default(0), + touchedAt: text("touched_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.workspaceSessionId, table.path] }), + ], +); + export const workspaceConversationBindings = sqliteTable( "workspace_conversation_bindings", { @@ -118,6 +136,8 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect; export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert; export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; +export type ChangeJournalRow = typeof changeJournal.$inferSelect; +export type NewChangeJournalRow = typeof changeJournal.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index e47f8121..10811553 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 2, name: "oauth-state" }, { version: 3, name: "local-agent-sessions" }, { version: 4, name: "workspace-conversation-bindings" }, + { version: 5, name: "change-journal" }, ]); } finally { database.close(); diff --git a/src/server.test.ts b/src/server.test.ts index 73eaf03b..8df90352 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -8,6 +8,8 @@ import { promisify } from "node:util"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { loadConfig, type ServerConfig } from "./config.js"; +import { openDatabase } from "./db/client.js"; +import { createChangeJournalManager } from "./change-journal.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { ProcessSessionManager } from "./process-sessions.js"; import { createMcpServer } from "./server.js"; @@ -133,10 +135,13 @@ test("checkout reuse and context suppression survive a registry restart", async await context.close(); const restoredStore = new SqliteWorkspaceStore(context.stateDir); + const restoredJournalDatabase = openDatabase(context.stateDir); const restoredServer = createMcpServer( context.config, new WorkspaceRegistry(context.config, restoredStore), createReviewCheckpointManager(), + createChangeJournalManager(restoredJournalDatabase.db), + "journal", new ProcessSessionManager(), [], [], @@ -150,6 +155,7 @@ test("checkout reuse and context suppression survive a registry restart", async await restoredClient.close(); await restoredServer.close(); restoredStore.close(); + restoredJournalDatabase.close(); }; t.after(closeRestored); @@ -175,7 +181,10 @@ interface ServerFixture { close: () => Promise; } -async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise { +async function fixture( + t: TestContext, + options: { git?: boolean; widgets?: "off" | "changes" | "full" } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); const project = join(root, "project"); const agentDir = join(root, "agent"); @@ -208,17 +217,21 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise DEVSPACE_ALLOWED_ROOTS: root, DEVSPACE_WORKTREE_ROOT: join(root, ".worktrees"), DEVSPACE_AGENT_DIR: agentDir, - DEVSPACE_WIDGETS: "full", + DEVSPACE_WIDGETS: options.widgets ?? "full", DEVSPACE_TOOL_MODE: "full", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", PORT: "1", }); + const journalDatabase = openDatabase(stateDir); + const changeJournal = createChangeJournalManager(journalDatabase.db); const store = new SqliteWorkspaceStore(stateDir); const workspaces = new WorkspaceRegistry(config, store); const server = createMcpServer( config, workspaces, createReviewCheckpointManager(), + changeJournal, + "journal", new ProcessSessionManager(), [], [], @@ -241,6 +254,7 @@ async function fixture(t: TestContext, options: { git?: boolean } = {}): Promise t.after(async () => { await close(); + journalDatabase.close(); await rm(root, { recursive: true, force: true }); }); @@ -291,3 +305,52 @@ function responseCard(result: Awaited>): Record; } + +function callTool( + client: Client, + name: string, + argumentsValue: Record, +): Promise>> { + const params = { + name, + arguments: argumentsValue, + } as Parameters[0]; + return client.callTool(params); +} + +test("show_changes reviews the change journal in a non-git workspace and re-baselines", async (t) => { + const context = await fixture(t, { widgets: "changes" }); + const open = await callOpen(context.client, context.project, "chat-1"); + const workspaceId = structuredContent(open).workspaceId as string; + + const writeRelative = "notes.txt"; + await callTool(context.client, "write", { + workspaceId, + path: writeRelative, + content: "alpha\n", + }); + + const first = await callTool(context.client, "show_changes", { workspaceId }); + assert.equal(responseText(first), "Changed 1 file (+1 -0)."); + const firstCard = responseCard(first); + assert.equal((firstCard.files as Array>)[0]?.type, "new"); + assert.match((firstCard.payload as Record).patch as string, /diff --git/); + + await callTool(context.client, "write", { + workspaceId, + path: writeRelative, + content: "alpha\nbeta\n", + }); + + const second = await callTool(context.client, "show_changes", { workspaceId }); + assert.equal(responseText(second), "Changed 1 file (+1 -0)."); + const secondCard = responseCard(second); + assert.equal((secondCard.files as Array>)[0]?.type, "change"); + assert.match((secondCard.payload as Record).patch as string, /\+beta/); + + const third = await callTool(context.client, "show_changes", { workspaceId }); + assert.equal(responseText(third), "No changes since last shown changes."); + + const fourth = await callTool(context.client, "show_changes", { workspaceId }); + assert.equal(responseText(fourth), "No changes since last shown changes."); +}); diff --git a/src/server.ts b/src/server.ts index 10527b1f..bdee92fa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -17,7 +17,7 @@ import { import express from "express"; import type { Request, Response } from "express"; import * as z from "zod/v4"; -import { applyPatch } from "./apply-patch.js"; +import { applyPatch, parsePatch } from "./apply-patch.js"; import { isArtifactDownloadSupportedPlatform, registerArtifactTools, @@ -53,6 +53,8 @@ import { createReviewCheckpointManager, type ReviewChangesResult, } from "./review-checkpoints.js"; +import { createChangeJournalManager, type ChangeJournalManager } from "./change-journal.js"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { openAiConversationScopeId } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; @@ -700,14 +702,19 @@ function registerCodexProcessTools( ); } +export type ReviewSource = "git" | "journal"; + export function createMcpServer( config: ServerConfig, workspaces: WorkspaceRegistry, reviewCheckpoints: ReturnType, + changeJournal: ChangeJournalManager, + reviewSource: ReviewSource, processSessions: ProcessSessionManager, localAgentProviders: LocalAgentProviderAvailability[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], ): McpServer { + const journalTouches = config.widgets === "changes" && reviewSource === "journal"; const server = new McpServer( { name: "devspace", @@ -1075,6 +1082,13 @@ export function createMcpServer( const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); workspaces.resolvePath(workspace, input.path); + if (journalTouches) { + await changeJournal.recordTouch({ + workspaceId, + root: workspace.root, + path: input.path, + }); + } const response = await writeFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -1162,6 +1176,13 @@ export function createMcpServer( const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); workspaces.resolvePath(workspace, input.path); + if (journalTouches) { + await changeJournal.recordTouch({ + workspaceId, + root: workspace.root, + path: input.path, + }); + } const response = await editFileTool(input, { cwd: workspace.root, root: workspace.root, @@ -1249,6 +1270,31 @@ export function createMcpServer( async ({ workspaceId, patch }) => { const startedAt = performance.now(); const workspace = workspaces.getWorkspace(workspaceId); + if (journalTouches) { + const actions = parsePatch(patch); + for (const action of actions) { + if (action.kind === "delete") { + await changeJournal.recordTouch({ + workspaceId, + root: workspace.root, + path: action.path, + }); + } else if (action.kind === "update" && action.moveTo) { + await changeJournal.recordTouch({ + workspaceId, + root: workspace.root, + path: action.moveTo, + previousPath: action.path, + }); + } else { + await changeJournal.recordTouch({ + workspaceId, + root: workspace.root, + path: action.path, + }); + } + } + } const applied = await applyPatch(workspace.root, patch); const paths = applied.files.map((file) => file.path).join(", "); const result = `Applied patch to ${applied.files.length} file(s): ${paths}`; @@ -1314,11 +1360,17 @@ export function createMcpServer( let review: ReviewChangesResult; try { - review = await reviewCheckpoints.reviewChanges({ - workspaceId, - root: workspace.root, - markReviewed: true, - }); + review = reviewSource === "git" + ? await reviewCheckpoints.reviewChanges({ + workspaceId, + root: workspace.root, + markReviewed: true, + }) + : await changeJournal.reviewChanges({ + workspaceId, + root: workspace.root, + markReviewed: true, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const content = [textBlock(`show_changes failed: ${message}`)]; @@ -1721,6 +1773,9 @@ export function createServer( const workspaceStore = createWorkspaceStore(config.stateDir); const workspaces = new WorkspaceRegistry(config, workspaceStore); const reviewCheckpoints = createReviewCheckpointManager(); + const reviewSource: ReviewSource = process.env.DEVSPACE_REVIEW_MODE === "git" ? "git" : "journal"; + const journalDatabase = openDatabase(config.stateDir); + const changeJournal = createChangeJournalManager(journalDatabase.db); const processSessions = new ProcessSessionManager(); const localAgentProviders = config.subagents ? getLocalAgentProviderAvailabilitySnapshot() @@ -1883,6 +1938,8 @@ export function createServer( config, workspaces, reviewCheckpoints, + changeJournal, + reviewSource, processSessions, localAgentProviders, incomingArtifactAdapters, @@ -1918,6 +1975,7 @@ export function createServer( processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); + journalDatabase.close(); })(); return closePromise; },