From c1c027efef33a436e3c8a927314eeb4766b04b16 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:22:30 +0530 Subject: [PATCH 1/9] feat(activity): persist workspace tool calls --- src/db/migrations.ts | 33 +++++++ src/db/schema.ts | 26 +++++ src/workspace-activity-store.test.ts | 52 ++++++++++ src/workspace-activity-store.ts | 140 +++++++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 src/workspace-activity-store.test.ts create mode 100644 src/workspace-activity-store.ts diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 8bb1f7e4f..cd98035d0 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -47,6 +47,11 @@ const migrations: Migration[] = [ name: "local-agent-turns", up: migrateLocalAgentTurns, }, + { + version: 9, + name: "workspace-tool-calls", + up: migrateWorkspaceToolCalls, + }, ]; export function migrateDatabase(sqlite: Database.Database): void { @@ -289,6 +294,34 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } +function migrateWorkspaceToolCalls(sqlite: Database.Database): void { + sqlite.exec(` + create table if not exists workspace_tool_calls ( + id integer primary key autoincrement, + workspace_session_id text, + conversation_scope_id text, + request_id text, + tool_name text not null, + arguments_json text not null, + result_json text, + error_json text, + started_at text not null, + completed_at text, + duration_ms integer, + review_ref text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create index if not exists workspace_tool_calls_workspace_idx + on workspace_tool_calls(workspace_session_id, id desc); + + create index if not exists workspace_tool_calls_review_ref_idx + on workspace_tool_calls(workspace_session_id, review_ref); + `); +} + function addColumnIfMissing( sqlite: Database.Database, table: "workspace_sessions" | "local_agent_sessions", diff --git a/src/db/schema.ts b/src/db/schema.ts index 77b234368..2ef93087a 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -56,6 +56,30 @@ export const workspaceConversationBindings = sqliteTable( ], ); +export const workspaceToolCalls = sqliteTable( + "workspace_tool_calls", + { + id: integer("id").primaryKey({ autoIncrement: true }), + workspaceSessionId: text("workspace_session_id").references(() => workspaceSessions.id, { + onDelete: "cascade", + }), + conversationScopeId: text("conversation_scope_id"), + requestId: text("request_id"), + toolName: text("tool_name").notNull(), + argumentsJson: text("arguments_json").notNull(), + resultJson: text("result_json"), + errorJson: text("error_json"), + startedAt: text("started_at").notNull(), + completedAt: text("completed_at"), + durationMs: integer("duration_ms"), + reviewRef: text("review_ref"), + }, + (table) => [ + index("workspace_tool_calls_workspace_idx").on(table.workspaceSessionId, table.id), + index("workspace_tool_calls_review_ref_idx").on(table.workspaceSessionId, table.reviewRef), + ], +); + export const oauthClients = sqliteTable( "oauth_clients", { @@ -123,5 +147,7 @@ export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; +export type WorkspaceToolCallRow = typeof workspaceToolCalls.$inferSelect; +export type NewWorkspaceToolCallRow = typeof workspaceToolCalls.$inferInsert; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/workspace-activity-store.test.ts b/src/workspace-activity-store.test.ts new file mode 100644 index 000000000..eb3a879e4 --- /dev/null +++ b/src/workspace-activity-store.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceActivityStore } from "./workspace-activity-store.js"; + +test("workspace activity persists raw tool calls across store reopen", async (t) => { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-store-test-")); + const workspaces = new SqliteWorkspaceStore(stateDir); + workspaces.createSession({ id: "ws_test", root: "/tmp/project", mode: "checkout" }); + workspaces.close(); + + const first = new WorkspaceActivityStore(stateDir); + const callId = first.startCall({ + workspaceId: "ws_test", + conversationScopeId: "conversation-1", + requestId: "request-1", + toolName: "read", + arguments: { workspace_id: "ws_test", path: "README.md" }, + startedAt: "2026-09-11T00:00:00.000Z", + }); + first.finishCall(callId, { + workspaceId: "ws_test", + result: { structuredContent: { result: "hello" } }, + completedAt: "2026-09-11T00:00:00.010Z", + durationMs: 10, + }); + first.close(); + + const reopened = new WorkspaceActivityStore(stateDir); + t.after(async () => { + reopened.close(); + await rm(stateDir, { recursive: true, force: true }); + }); + + assert.deepEqual(reopened.listCalls({ workspaceId: "ws_test", limit: 10 }), [ + { + id: callId, + workspaceId: "ws_test", + conversationScopeId: "conversation-1", + requestId: "request-1", + toolName: "read", + arguments: { workspace_id: "ws_test", path: "README.md" }, + result: { structuredContent: { result: "hello" } }, + startedAt: "2026-09-11T00:00:00.000Z", + completedAt: "2026-09-11T00:00:00.010Z", + durationMs: 10, + }, + ]); +}); diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts new file mode 100644 index 000000000..efcc77362 --- /dev/null +++ b/src/workspace-activity-store.ts @@ -0,0 +1,140 @@ +import { and, desc, eq, lt } from "drizzle-orm"; +import { openDatabase, type DatabaseHandle } from "./db/client.js"; +import { workspaceSessions, workspaceToolCalls, type WorkspaceToolCallRow } from "./db/schema.js"; + +export interface WorkspaceToolCall { + id: number; + workspaceId?: string; + conversationScopeId?: string; + requestId?: string; + toolName: string; + arguments: unknown; + result?: unknown; + error?: unknown; + startedAt: string; + completedAt?: string; + durationMs?: number; + reviewRef?: string; +} + +export interface StartWorkspaceToolCall { + workspaceId?: string; + conversationScopeId?: string; + requestId?: string; + toolName: string; + arguments: unknown; + startedAt: string; +} + +export interface FinishWorkspaceToolCall { + workspaceId?: string; + result?: unknown; + error?: unknown; + completedAt: string; + durationMs: number; + reviewRef?: string; +} + +export class WorkspaceActivityStore { + private readonly database: DatabaseHandle; + + constructor(stateDir: string) { + this.database = openDatabase(stateDir); + } + + startCall(input: StartWorkspaceToolCall): number { + const inserted = this.database.db + .insert(workspaceToolCalls) + .values({ + workspaceSessionId: this.existingWorkspaceId(input.workspaceId) ?? null, + conversationScopeId: input.conversationScopeId ?? null, + requestId: input.requestId ?? null, + toolName: input.toolName, + argumentsJson: JSON.stringify(input.arguments), + startedAt: input.startedAt, + }) + .run(); + + return Number(inserted.lastInsertRowid); + } + + finishCall(id: number, input: FinishWorkspaceToolCall): void { + this.database.db + .update(workspaceToolCalls) + .set({ + ...(input.workspaceId + ? { workspaceSessionId: this.existingWorkspaceId(input.workspaceId) ?? null } + : {}), + resultJson: input.result === undefined ? null : JSON.stringify(input.result), + errorJson: input.error === undefined ? null : JSON.stringify(input.error), + completedAt: input.completedAt, + durationMs: input.durationMs, + reviewRef: input.reviewRef ?? null, + }) + .where(eq(workspaceToolCalls.id, id)) + .run(); + } + + listCalls(input: { + workspaceId: string; + beforeId?: number; + limit: number; + }): WorkspaceToolCall[] { + const clauses = [eq(workspaceToolCalls.workspaceSessionId, input.workspaceId)]; + if (input.beforeId !== undefined) clauses.push(lt(workspaceToolCalls.id, input.beforeId)); + + return this.database.db + .select() + .from(workspaceToolCalls) + .where(and(...clauses)) + .orderBy(desc(workspaceToolCalls.id)) + .limit(input.limit) + .all() + .map(rowToWorkspaceToolCall); + } + + getCall(workspaceId: string, callId: number): WorkspaceToolCall | undefined { + const row = this.database.db + .select() + .from(workspaceToolCalls) + .where( + and( + eq(workspaceToolCalls.workspaceSessionId, workspaceId), + eq(workspaceToolCalls.id, callId), + ), + ) + .get(); + return row ? rowToWorkspaceToolCall(row) : undefined; + } + + close(): void { + this.database.close(); + } + + private existingWorkspaceId(workspaceId: string | undefined): string | undefined { + if (!workspaceId) return undefined; + const row = this.database.db + .select({ id: workspaceSessions.id }) + .from(workspaceSessions) + .where(eq(workspaceSessions.id, workspaceId)) + .get(); + return row?.id; + } +} + +function rowToWorkspaceToolCall(row: WorkspaceToolCallRow): WorkspaceToolCall { + return { + id: row.id, + ...(row.workspaceSessionId ? { workspaceId: row.workspaceSessionId } : {}), + ...(row.conversationScopeId ? { conversationScopeId: row.conversationScopeId } : {}), + ...(row.requestId ? { requestId: row.requestId } : {}), + toolName: row.toolName, + arguments: JSON.parse(row.argumentsJson) as unknown, + ...(row.resultJson ? { result: JSON.parse(row.resultJson) as unknown } : {}), + ...(row.errorJson ? { error: JSON.parse(row.errorJson) as unknown } : {}), + startedAt: row.startedAt, + ...(row.completedAt ? { completedAt: row.completedAt } : {}), + ...(row.durationMs !== null ? { durationMs: row.durationMs } : {}), + ...(row.reviewRef ? { reviewRef: row.reviewRef } : {}), + }; +} From 1b31b24d689fc88342d4241976edda03edfa054a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:23:14 +0530 Subject: [PATCH 2/9] feat(activity): record registered tool executions --- src/server.ts | 40 ++++++-- src/workspace-activity-journal.test.ts | 81 ++++++++++++++++ src/workspace-activity-journal.ts | 126 +++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 src/workspace-activity-journal.test.ts create mode 100644 src/workspace-activity-journal.ts diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..06256e577 100644 --- a/src/server.ts +++ b/src/server.ts @@ -46,6 +46,7 @@ import { shutdownHttpServer } from "./server-shutdown.js"; import { formatPathForPrompt } from "./skills.js"; import { DEVSPACE_VERSION } from "./version.js"; import { createWorkspaceStore } from "./workspace-store.js"; +import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { getLocalAgentProviderAvailabilitySnapshot, @@ -313,6 +314,7 @@ export function createMcpServer( resolveLocalAgentProviders: () => LocalAgentProviderStatus[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], trackToolActivity?: TrackToolActivity, + workspaceActivityJournal?: WorkspaceActivityJournal, ): McpServer { const toolSurface = getToolSurface(config.toolMode); const server = new McpServer( @@ -331,6 +333,7 @@ export function createMcpServer( resolveLocalAgentProviders, incomingArtifactAdapters, trackToolActivity, + workspaceActivityJournal, ); return server; } @@ -344,9 +347,10 @@ function registerMcpSurface( resolveLocalAgentProviders: () => LocalAgentProviderStatus[], incomingArtifactAdapters: readonly IncomingArtifactAdapter[], trackToolActivity?: TrackToolActivity, + workspaceActivityJournal?: WorkspaceActivityJournal, ): void { - const registrationTarget = trackToolActivity - ? withTrackedToolHandlers(server, trackToolActivity) + const registrationTarget = trackToolActivity || workspaceActivityJournal + ? withObservedToolHandlers(server, { trackToolActivity, workspaceActivityJournal }) : server; const toolSurface = getToolSurface(config.toolMode); @@ -771,18 +775,33 @@ function registerMcpSurface( } } -function withTrackedToolHandlers( +function withObservedToolHandlers( server: McpRegistrationTarget, - trackToolActivity: TrackToolActivity, + options: { + trackToolActivity?: TrackToolActivity; + workspaceActivityJournal?: WorkspaceActivityJournal; + }, ): McpRegistrationTarget { return { registerTool: ((...args: unknown[]) => { + const toolName = args[0] as string; const handler = args.at(-1) as (...handlerArgs: unknown[]) => unknown; return (server.registerTool as (...callArgs: unknown[]) => unknown)( ...args.slice(0, -1), - (...handlerArgs: unknown[]) => trackToolActivity( - () => Promise.resolve(handler(...handlerArgs)), - ), + (...handlerArgs: unknown[]) => { + const operation = () => Promise.resolve(handler(...handlerArgs)); + const observedOperation = options.workspaceActivityJournal + ? () => options.workspaceActivityJournal!.capture({ + toolName, + arguments: handlerArgs[0], + extra: (handlerArgs[1] ?? {}) as Record, + operation, + }) + : operation; + return options.trackToolActivity + ? options.trackToolActivity(observedOperation) + : observedOperation(); + }, ); }) as McpRegistrationTarget["registerTool"], registerResource: server.registerResource.bind(server), @@ -819,6 +838,11 @@ export function createServer( const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); const toolActivities = new ToolActivityTracker(); + const workspaceActivityJournal = new WorkspaceActivityJournal(config.stateDir, (error) => { + logEvent(config.logging, "warn", "workspace_activity_journal_error", { + error: error instanceof Error ? error.message : String(error), + }); + }); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), @@ -838,6 +862,7 @@ export function createServer( resolveLocalAgentProviders, incomingArtifactAdapters, toolActivities.track, + workspaceActivityJournal, ); }); const logMcpHandlerError = (error: Error) => logEvent( @@ -974,6 +999,7 @@ export function createServer( }); } await toolActivities.waitForIdle(); + workspaceActivityJournal.close(); processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); diff --git a/src/workspace-activity-journal.test.ts b/src/workspace-activity-journal.test.ts new file mode 100644 index 000000000..e84fd93ee --- /dev/null +++ b/src/workspace-activity-journal.test.ts @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; +import { WorkspaceActivityStore } from "./workspace-activity-store.js"; +import { SqliteWorkspaceStore } from "./workspace-store.js"; + +test("journal associates open_workspace after the tool creates its workspace", async (t) => { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); + const workspaces = new SqliteWorkspaceStore(stateDir); + const journal = new WorkspaceActivityJournal(stateDir); + t.after(async () => { + journal.close(); + workspaces.close(); + await rm(stateDir, { recursive: true, force: true }); + }); + + await journal.capture({ + toolName: "open_workspace", + arguments: { path: "/tmp/project" }, + extra: { _meta: { "openai/session": "conversation-1" }, requestId: 17 }, + operation: async () => { + workspaces.createSession({ id: "ws_opened", root: "/tmp/project" }); + return { structuredContent: { workspace_id: "ws_opened" } }; + }, + }); + + const activity = new WorkspaceActivityStore(stateDir); + t.after(() => activity.close()); + const calls = activity.listCalls({ workspaceId: "ws_opened", limit: 10 }); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.toolName, "open_workspace"); + assert.equal(calls[0]?.conversationScopeId, "conversation-1"); + assert.equal(calls[0]?.requestId, "17"); +}); + +test("journal ignores historical show_changes replays", async (t) => { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); + const workspaces = new SqliteWorkspaceStore(stateDir); + workspaces.createSession({ id: "ws_test", root: "/tmp/project" }); + const journal = new WorkspaceActivityJournal(stateDir); + t.after(async () => { + journal.close(); + workspaces.close(); + await rm(stateDir, { recursive: true, force: true }); + }); + + await journal.capture({ + toolName: "show_changes", + arguments: { workspace_id: "ws_test" }, + extra: { _meta: { "devspace/reviewRef": "abc" } }, + operation: async () => ({ structuredContent: { workspace_id: "ws_test", review_ref: "abc" } }), + }); + + const activity = new WorkspaceActivityStore(stateDir); + t.after(() => activity.close()); + assert.deepEqual(activity.listCalls({ workspaceId: "ws_test", limit: 10 }), []); +}); + +test("journal failures never replace the tool result", async (t) => { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); + const errors: unknown[] = []; + const journal = new WorkspaceActivityJournal(stateDir, (error) => errors.push(error)); + t.after(async () => { + journal.close(); + await rm(stateDir, { recursive: true, force: true }); + }); + + journal.close(); + const result = await journal.capture({ + toolName: "read", + arguments: { workspace_id: "missing" }, + extra: {}, + operation: async () => "tool result", + }); + + assert.equal(result, "tool result"); + assert.equal(errors.length, 1); +}); diff --git a/src/workspace-activity-journal.ts b/src/workspace-activity-journal.ts new file mode 100644 index 000000000..41e18f259 --- /dev/null +++ b/src/workspace-activity-journal.ts @@ -0,0 +1,126 @@ +import { conversationScopeIdFromRequestMeta } from "./request-meta.js"; +import { WorkspaceActivityStore } from "./workspace-activity-store.js"; + +type ToolHandlerExtra = { + _meta?: Record; + requestId?: unknown; +}; + +export class WorkspaceActivityJournal { + private readonly store: WorkspaceActivityStore; + + constructor( + stateDir: string, + private readonly onError: (error: unknown) => void = () => {}, + ) { + this.store = new WorkspaceActivityStore(stateDir); + } + + async capture(input: { + toolName: string; + arguments: unknown; + extra: ToolHandlerExtra; + operation: () => Promise; + }): Promise { + if (isHistoricalReviewReplay(input.toolName, input.extra._meta)) { + return input.operation(); + } + + const startedAt = new Date(); + const startedAtMs = performance.now(); + const argumentWorkspaceId = workspaceIdFromValue(input.arguments); + let callId: number | undefined; + + try { + callId = this.store.startCall({ + workspaceId: argumentWorkspaceId, + conversationScopeId: conversationScopeIdFromRequestMeta(input.extra._meta), + requestId: requestIdFromExtra(input.extra.requestId), + toolName: input.toolName, + arguments: input.arguments, + startedAt: startedAt.toISOString(), + }); + } catch (error) { + this.onError(error); + } + + try { + const result = await input.operation(); + if (callId !== undefined) { + this.finishSafely(callId, { + workspaceId: workspaceIdFromValue(result) ?? argumentWorkspaceId, + result, + completedAt: new Date().toISOString(), + durationMs: Math.round(performance.now() - startedAtMs), + reviewRef: reviewRefFromValue(result), + }); + } + return result; + } catch (error) { + if (callId !== undefined) { + this.finishSafely(callId, { + workspaceId: argumentWorkspaceId, + error: serializeError(error), + completedAt: new Date().toISOString(), + durationMs: Math.round(performance.now() - startedAtMs), + }); + } + throw error; + } + } + + close(): void { + this.store.close(); + } + + private finishSafely( + callId: number, + input: Parameters[1], + ): void { + try { + this.store.finishCall(callId, input); + } catch (error) { + this.onError(error); + } + } +} + +function workspaceIdFromValue(value: unknown): string | undefined { + const record = asRecord(value); + const direct = record?.workspace_id; + if (typeof direct === "string" && direct.length > 0) return direct; + + const structured = asRecord(record?.structuredContent); + const nested = structured?.workspace_id; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; +} + +function reviewRefFromValue(value: unknown): string | undefined { + const structured = asRecord(asRecord(value)?.structuredContent); + const reviewRef = structured?.review_ref; + return typeof reviewRef === "string" && reviewRef.length > 0 ? reviewRef : undefined; +} + +function requestIdFromExtra(value: unknown): string | undefined { + return typeof value === "string" || typeof value === "number" ? String(value) : undefined; +} + +function isHistoricalReviewReplay( + toolName: string, + meta: Record | undefined, +): boolean { + return toolName === "show_changes" && typeof meta?.["devspace/reviewRef"] === "string"; +} + +function serializeError(error: unknown): { name: string; message: string } { + if (error instanceof Error) { + return { name: error.name, message: error.message }; + } + return { name: typeof error, message: String(error) }; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} From c624f113f3344998ecad3b9d4b5265b376176946 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:24:45 +0530 Subject: [PATCH 3/9] feat(activity): group tool calls by review boundary --- src/server.test.ts | 70 +++++++++++++++++++++++++++- src/workspace-activity.test.ts | 56 +++++++++++++++++++++++ src/workspace-activity.ts | 83 ++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/workspace-activity.test.ts create mode 100644 src/workspace-activity.ts diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..543047fdb 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -18,6 +18,9 @@ import { createMcpServer, createServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; +import { groupWorkspaceToolCalls } from "./workspace-activity.js"; +import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; +import { WorkspaceActivityStore } from "./workspace-activity-store.js"; const execFileAsync = promisify(execFile); @@ -312,6 +315,59 @@ test("show_changes can reopen a historical review without advancing the checkpoi ); }); +test("workspace activity groups a real tool sequence under its review ref", async (t) => { + const context = await fixture(t, { git: true, captureActivity: true }); + assert.ok(context.activity); + const workspaceId = structuredContent( + await callOpen(context.client, context.project, "activity-sequence"), + ).workspace_id; + assert.ok(typeof workspaceId === "string"); + + await context.client.callTool({ + name: "read", + arguments: { workspace_id: workspaceId, path: "README.md" }, + }); + await context.client.callTool({ + name: "exec_command", + arguments: { workspace_id: workspaceId, cmd: "printf activity" }, + }); + await context.client.callTool({ + name: "apply_patch", + arguments: { + workspace_id: workspaceId, + patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-hello\n+hello activity\n*** End Patch", + }, + }); + const shown = structuredContent(await context.client.callTool({ + name: "show_changes", + arguments: { workspace_id: workspaceId }, + })); + const reviewRef = shown.review_ref; + assert.ok(typeof reviewRef === "string"); + + const groups = groupWorkspaceToolCalls( + context.activity.listCalls({ workspaceId, limit: 20 }), + ); + const reviewed = groups.find((group) => group.reviewRef === reviewRef); + assert.ok(reviewed); + assert.deepEqual(reviewed.calls.map((call) => call.toolName), [ + "read", + "exec_command", + "apply_patch", + "show_changes", + ]); + + await context.client.callTool({ + name: "show_changes", + arguments: { workspace_id: workspaceId }, + _meta: { "devspace/reviewRef": reviewRef }, + } as Parameters[0]); + assert.equal( + context.activity.listCalls({ workspaceId, limit: 20 }).filter((call) => call.toolName === "show_changes").length, + 1, + ); +}); + test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const providerNote = "available"; const context = await fixture(t, { @@ -640,6 +696,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { interface ServerFixture { client: Client; project: string; + activity?: WorkspaceActivityStore; } function schemaPropertyPaths( @@ -722,6 +779,7 @@ async function fixture( subagents?: SubagentsConfig; toolMode?: ToolMode; uiEnabled?: boolean; + captureActivity?: boolean; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -791,6 +849,12 @@ async function fixture( resolveProviderAvailability(), ); const store = new SqliteWorkspaceStore(stateDir); + const activityJournal = options.captureActivity + ? new WorkspaceActivityJournal(stateDir) + : undefined; + const activity = options.captureActivity + ? new WorkspaceActivityStore(stateDir) + : undefined; const workspaces = new WorkspaceRegistry(config, store); const server = createMcpServer( config, @@ -799,6 +863,8 @@ async function fixture( new ProcessSessionManager(), resolveLocalAgentProviders, [], + undefined, + activityJournal, ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); @@ -813,6 +879,8 @@ async function fixture( closed = true; await client.close(); await server.close(); + activity?.close(); + activityJournal?.close(); store.close(); }; @@ -821,7 +889,7 @@ async function fixture( await rm(root, { recursive: true, force: true }); }); - return { client, project }; + return { client, project, ...(activity ? { activity } : {}) }; } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspace-activity.test.ts b/src/workspace-activity.test.ts new file mode 100644 index 000000000..8506d0783 --- /dev/null +++ b/src/workspace-activity.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { groupWorkspaceToolCalls } from "./workspace-activity.js"; +import type { WorkspaceToolCall } from "./workspace-activity-store.js"; + +test("show_changes closes one review-backed activity group", () => { + const calls = [ + call(1, "read", "2026-09-11T00:00:00.000Z"), + call(2, "exec_command", "2026-09-11T00:00:01.000Z"), + call(3, "apply_patch", "2026-09-11T00:00:02.000Z"), + { ...call(4, "show_changes", "2026-09-11T00:00:03.000Z"), reviewRef: "abc" }, + call(5, "read", "2026-09-11T00:00:04.000Z"), + ]; + + const groups = groupWorkspaceToolCalls(calls); + assert.equal(groups.length, 2); + assert.equal(groups[1]?.id, "review:abc"); + assert.equal(groups[1]?.kind, "review"); + assert.deepEqual(groups[1]?.calls.map((entry) => entry.toolName), [ + "read", + "exec_command", + "apply_patch", + "show_changes", + ]); + assert.equal(groups[0]?.id, "activity:5"); +}); + +test("read-only activity uses inactivity gaps without mixing conversations", () => { + const groups = groupWorkspaceToolCalls([ + call(1, "read", "2026-09-11T00:00:00.000Z", "conversation-a"), + call(2, "read", "2026-09-11T00:00:30.000Z", "conversation-b"), + call(3, "read", "2026-09-11T00:03:00.000Z", "conversation-a"), + ]); + + assert.equal(groups.length, 3); + assert.deepEqual(groups.map((group) => group.calls.map((entry) => entry.id)), [[3], [2], [1]]); + assert.ok(groups.every((group) => group.kind === "inferred")); +}); + +function call( + id: number, + toolName: string, + startedAt: string, + conversationScopeId = "conversation-a", +): WorkspaceToolCall { + return { + id, + workspaceId: "ws_test", + conversationScopeId, + toolName, + arguments: {}, + startedAt, + completedAt: startedAt, + durationMs: 0, + }; +} diff --git a/src/workspace-activity.ts b/src/workspace-activity.ts new file mode 100644 index 000000000..7b1e566de --- /dev/null +++ b/src/workspace-activity.ts @@ -0,0 +1,83 @@ +import type { WorkspaceToolCall } from "./workspace-activity-store.js"; + +const ACTIVITY_GROUP_GAP_MS = 2 * 60 * 1000; + +export interface WorkspaceActivityGroup { + id: string; + kind: "review" | "inferred"; + startedAt: string; + completedAt?: string; + reviewRef?: string; + calls: WorkspaceToolCall[]; +} + +export function groupWorkspaceToolCalls( + calls: ReadonlyArray, +): WorkspaceActivityGroup[] { + const byConversation = new Map(); + for (const call of calls) { + const key = call.conversationScopeId ?? "__unscoped__"; + const conversationCalls = byConversation.get(key) ?? []; + conversationCalls.push(call); + byConversation.set(key, conversationCalls); + } + + const groups = [...byConversation.values()].flatMap(groupConversationCalls); + return [...groups].sort((left, right) => { + const timeOrder = right.startedAt.localeCompare(left.startedAt); + if (timeOrder !== 0) return timeOrder; + return (right.calls[0]?.id ?? 0) - (left.calls[0]?.id ?? 0); + }); +} + +function groupConversationCalls(calls: WorkspaceToolCall[]): WorkspaceActivityGroup[] { + const ordered = [...calls].sort((left, right) => { + const timeOrder = left.startedAt.localeCompare(right.startedAt); + return timeOrder !== 0 ? timeOrder : left.id - right.id; + }); + const groups: WorkspaceActivityGroup[] = []; + let current: WorkspaceToolCall[] = []; + + const flush = () => { + if (current.length === 0) return; + groups.push(toActivityGroup(current)); + current = []; + }; + + for (const call of ordered) { + const previous = current.at(-1); + if (previous && callStartedAfterGap(previous, call)) flush(); + + current.push(call); + if (isReviewBoundary(call)) flush(); + } + + flush(); + return groups; +} + +function toActivityGroup(calls: WorkspaceToolCall[]): WorkspaceActivityGroup { + const first = calls[0]!; + const last = calls.at(-1)!; + const reviewRef = isReviewBoundary(last) ? last.reviewRef : undefined; + return { + id: reviewRef ? `review:${reviewRef}` : `activity:${first.id}`, + kind: reviewRef ? "review" : "inferred", + startedAt: first.startedAt, + ...(last.completedAt ? { completedAt: last.completedAt } : {}), + ...(reviewRef ? { reviewRef } : {}), + calls, + }; +} + +function callStartedAfterGap(previous: WorkspaceToolCall, next: WorkspaceToolCall): boolean { + const previousEnd = Date.parse(previous.completedAt ?? previous.startedAt); + const nextStart = Date.parse(next.startedAt); + return Number.isFinite(previousEnd) + && Number.isFinite(nextStart) + && nextStart - previousEnd > ACTIVITY_GROUP_GAP_MS; +} + +function isReviewBoundary(call: WorkspaceToolCall): call is WorkspaceToolCall & { reviewRef: string } { + return call.toolName === "show_changes" && typeof call.reviewRef === "string"; +} From 11f3abaa13b11971a8448a9996a6cd0f7b2aba73 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:25:47 +0530 Subject: [PATCH 4/9] perf(activity): list tool metadata without raw payloads --- src/workspace-activity-store.test.ts | 12 ++++++ src/workspace-activity-store.ts | 64 +++++++++++++++++++++++++--- src/workspace-activity.test.ts | 5 +-- src/workspace-activity.ts | 23 ++++++---- 4 files changed, 85 insertions(+), 19 deletions(-) diff --git a/src/workspace-activity-store.test.ts b/src/workspace-activity-store.test.ts index eb3a879e4..8ff02434d 100644 --- a/src/workspace-activity-store.test.ts +++ b/src/workspace-activity-store.test.ts @@ -49,4 +49,16 @@ test("workspace activity persists raw tool calls across store reopen", async (t) durationMs: 10, }, ]); + assert.deepEqual(reopened.listCallSummaries({ workspaceId: "ws_test", limit: 10 }), [ + { + id: callId, + workspaceId: "ws_test", + conversationScopeId: "conversation-1", + requestId: "request-1", + toolName: "read", + startedAt: "2026-09-11T00:00:00.000Z", + completedAt: "2026-09-11T00:00:00.010Z", + durationMs: 10, + }, + ]); }); diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts index efcc77362..799d13afc 100644 --- a/src/workspace-activity-store.ts +++ b/src/workspace-activity-store.ts @@ -2,21 +2,24 @@ import { and, desc, eq, lt } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, workspaceToolCalls, type WorkspaceToolCallRow } from "./db/schema.js"; -export interface WorkspaceToolCall { +export interface WorkspaceToolCallSummary { id: number; workspaceId?: string; conversationScopeId?: string; requestId?: string; toolName: string; - arguments: unknown; - result?: unknown; - error?: unknown; startedAt: string; completedAt?: string; durationMs?: number; reviewRef?: string; } +export interface WorkspaceToolCall extends WorkspaceToolCallSummary { + arguments: unknown; + result?: unknown; + error?: unknown; +} + export interface StartWorkspaceToolCall { workspaceId?: string; conversationScopeId?: string; @@ -93,6 +96,34 @@ export class WorkspaceActivityStore { .map(rowToWorkspaceToolCall); } + listCallSummaries(input: { + workspaceId: string; + beforeId?: number; + limit: number; + }): WorkspaceToolCallSummary[] { + const clauses = [eq(workspaceToolCalls.workspaceSessionId, input.workspaceId)]; + if (input.beforeId !== undefined) clauses.push(lt(workspaceToolCalls.id, input.beforeId)); + + return this.database.db + .select({ + id: workspaceToolCalls.id, + workspaceSessionId: workspaceToolCalls.workspaceSessionId, + conversationScopeId: workspaceToolCalls.conversationScopeId, + requestId: workspaceToolCalls.requestId, + toolName: workspaceToolCalls.toolName, + startedAt: workspaceToolCalls.startedAt, + completedAt: workspaceToolCalls.completedAt, + durationMs: workspaceToolCalls.durationMs, + reviewRef: workspaceToolCalls.reviewRef, + }) + .from(workspaceToolCalls) + .where(and(...clauses)) + .orderBy(desc(workspaceToolCalls.id)) + .limit(input.limit) + .all() + .map(rowToWorkspaceToolCallSummary); + } + getCall(workspaceId: string, callId: number): WorkspaceToolCall | undefined { const row = this.database.db .select() @@ -123,15 +154,34 @@ export class WorkspaceActivityStore { } function rowToWorkspaceToolCall(row: WorkspaceToolCallRow): WorkspaceToolCall { + return { + ...rowToWorkspaceToolCallSummary(row), + arguments: JSON.parse(row.argumentsJson) as unknown, + ...(row.resultJson ? { result: JSON.parse(row.resultJson) as unknown } : {}), + ...(row.errorJson ? { error: JSON.parse(row.errorJson) as unknown } : {}), + }; +} + +function rowToWorkspaceToolCallSummary( + row: Pick< + WorkspaceToolCallRow, + | "id" + | "workspaceSessionId" + | "conversationScopeId" + | "requestId" + | "toolName" + | "startedAt" + | "completedAt" + | "durationMs" + | "reviewRef" + >, +): WorkspaceToolCallSummary { return { id: row.id, ...(row.workspaceSessionId ? { workspaceId: row.workspaceSessionId } : {}), ...(row.conversationScopeId ? { conversationScopeId: row.conversationScopeId } : {}), ...(row.requestId ? { requestId: row.requestId } : {}), toolName: row.toolName, - arguments: JSON.parse(row.argumentsJson) as unknown, - ...(row.resultJson ? { result: JSON.parse(row.resultJson) as unknown } : {}), - ...(row.errorJson ? { error: JSON.parse(row.errorJson) as unknown } : {}), startedAt: row.startedAt, ...(row.completedAt ? { completedAt: row.completedAt } : {}), ...(row.durationMs !== null ? { durationMs: row.durationMs } : {}), diff --git a/src/workspace-activity.test.ts b/src/workspace-activity.test.ts index 8506d0783..80abec643 100644 --- a/src/workspace-activity.test.ts +++ b/src/workspace-activity.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { groupWorkspaceToolCalls } from "./workspace-activity.js"; -import type { WorkspaceToolCall } from "./workspace-activity-store.js"; +import type { WorkspaceToolCallSummary } from "./workspace-activity-store.js"; test("show_changes closes one review-backed activity group", () => { const calls = [ @@ -42,13 +42,12 @@ function call( toolName: string, startedAt: string, conversationScopeId = "conversation-a", -): WorkspaceToolCall { +): WorkspaceToolCallSummary { return { id, workspaceId: "ws_test", conversationScopeId, toolName, - arguments: {}, startedAt, completedAt: startedAt, durationMs: 0, diff --git a/src/workspace-activity.ts b/src/workspace-activity.ts index 7b1e566de..31dbc6f2f 100644 --- a/src/workspace-activity.ts +++ b/src/workspace-activity.ts @@ -1,4 +1,4 @@ -import type { WorkspaceToolCall } from "./workspace-activity-store.js"; +import type { WorkspaceToolCallSummary } from "./workspace-activity-store.js"; const ACTIVITY_GROUP_GAP_MS = 2 * 60 * 1000; @@ -8,13 +8,13 @@ export interface WorkspaceActivityGroup { startedAt: string; completedAt?: string; reviewRef?: string; - calls: WorkspaceToolCall[]; + calls: WorkspaceToolCallSummary[]; } export function groupWorkspaceToolCalls( - calls: ReadonlyArray, + calls: ReadonlyArray, ): WorkspaceActivityGroup[] { - const byConversation = new Map(); + const byConversation = new Map(); for (const call of calls) { const key = call.conversationScopeId ?? "__unscoped__"; const conversationCalls = byConversation.get(key) ?? []; @@ -30,13 +30,13 @@ export function groupWorkspaceToolCalls( }); } -function groupConversationCalls(calls: WorkspaceToolCall[]): WorkspaceActivityGroup[] { +function groupConversationCalls(calls: WorkspaceToolCallSummary[]): WorkspaceActivityGroup[] { const ordered = [...calls].sort((left, right) => { const timeOrder = left.startedAt.localeCompare(right.startedAt); return timeOrder !== 0 ? timeOrder : left.id - right.id; }); const groups: WorkspaceActivityGroup[] = []; - let current: WorkspaceToolCall[] = []; + let current: WorkspaceToolCallSummary[] = []; const flush = () => { if (current.length === 0) return; @@ -56,7 +56,7 @@ function groupConversationCalls(calls: WorkspaceToolCall[]): WorkspaceActivityGr return groups; } -function toActivityGroup(calls: WorkspaceToolCall[]): WorkspaceActivityGroup { +function toActivityGroup(calls: WorkspaceToolCallSummary[]): WorkspaceActivityGroup { const first = calls[0]!; const last = calls.at(-1)!; const reviewRef = isReviewBoundary(last) ? last.reviewRef : undefined; @@ -70,7 +70,10 @@ function toActivityGroup(calls: WorkspaceToolCall[]): WorkspaceActivityGroup { }; } -function callStartedAfterGap(previous: WorkspaceToolCall, next: WorkspaceToolCall): boolean { +function callStartedAfterGap( + previous: WorkspaceToolCallSummary, + next: WorkspaceToolCallSummary, +): boolean { const previousEnd = Date.parse(previous.completedAt ?? previous.startedAt); const nextStart = Date.parse(next.startedAt); return Number.isFinite(previousEnd) @@ -78,6 +81,8 @@ function callStartedAfterGap(previous: WorkspaceToolCall, next: WorkspaceToolCal && nextStart - previousEnd > ACTIVITY_GROUP_GAP_MS; } -function isReviewBoundary(call: WorkspaceToolCall): call is WorkspaceToolCall & { reviewRef: string } { +function isReviewBoundary( + call: WorkspaceToolCallSummary, +): call is WorkspaceToolCallSummary & { reviewRef: string } { return call.toolName === "show_changes" && typeof call.reviewRef === "string"; } From 249ae4467ae4fee82abdf21d069159edddbbb15a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:17:25 +0530 Subject: [PATCH 5/9] feat(review): pin each review checkpoint Co-Authored-By: Claude Opus 5.5 --- src/review-checkpoints.test.ts | 20 +++++++++ src/review-checkpoints.ts | 15 ++++++- src/workspace-activity-store.test.ts | 64 ---------------------------- src/workspace-activity.test.ts | 55 ------------------------ 4 files changed, 34 insertions(+), 120 deletions(-) delete mode 100644 src/workspace-activity-store.test.ts delete mode 100644 src/workspace-activity.test.ts diff --git a/src/review-checkpoints.test.ts b/src/review-checkpoints.test.ts index 0a6dfce81..b58d494c8 100644 --- a/src/review-checkpoints.test.ts +++ b/src/review-checkpoints.test.ts @@ -101,6 +101,26 @@ test("historical review refs survive later reviews and manager restarts", async assert.doesNotMatch(restoredFirst.patch, /\+second/); }); +test("pinned checkpoints stay reviewable after the baseline history is rewritten", async (t) => { + const root = await committedRepository(t); + const manager = createReviewCheckpointManager(); + await manager.initializeWorkspace({ workspaceId: "ws_pinned", root }); + + await writeFile(join(root, "README.md"), "hello\nfirst\n"); + const first = await manager.reviewChanges({ workspaceId: "ws_pinned", root }); + + // Losing the baseline makes the next review restart from workspace open, so + // the first checkpoint is no longer an ancestor of the baseline. + await deleteReviewRef(root, "ws_pinned", "baseline"); + const restarted = createReviewCheckpointManager(); + await writeFile(join(root, "README.md"), "hello\nfirst\nsecond\n"); + await restarted.reviewChanges({ workspaceId: "ws_pinned", root }); + + const restored = await restarted.reviewByRef({ workspaceId: "ws_pinned", root, reviewRef: first.reviewRef }); + assert.equal(restored.patch, first.patch); + assert.equal((await readReviewRef(root, first.reviewRef)).patch, first.patch); +}); + test("review refs are scoped to the workspace review history", async (t) => { const root = await committedRepository(t); const manager = createReviewCheckpointManager(); diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 1c43778ca..3fe500fee 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -36,6 +36,7 @@ interface WorkspaceReviewState { gitRoot?: string; openRef: string; baselineRef: string; + checkpointRefPrefix: string; openRefAvailable: boolean; baselineRefAvailable: boolean; diagnostic?: string; @@ -123,6 +124,10 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { const review = await readReviewBetween(state.gitRoot, baseline, current); if (markReviewed) { + // The baseline only reaches the latest checkpoint, and a workspace-open + // fallback rewrites its history. Pin each checkpoint so past turns stay + // reviewable instead of becoming garbage-collectable. + await git(state.gitRoot, ["update-ref", `${state.checkpointRefPrefix}/${current}`, current]); await git(state.gitRoot, ["update-ref", state.baselineRef, current]); state.baselineRefAvailable = true; } @@ -159,6 +164,12 @@ export function createReviewCheckpointManager(): ReviewCheckpointManager { commitForRef(state.gitRoot, state.baselineRef), resolveReviewCommitOrUndefined(state.gitRoot, reviewRef), ]); + if ( + reviewCommit + && await commitForRef(state.gitRoot, `${state.checkpointRefPrefix}/${reviewCommit}`) === reviewCommit + ) { + return readReviewCommit(state.gitRoot, reviewCommit); + } if ( !openCommit || !baselineCommit @@ -274,11 +285,12 @@ async function commitForRef(gitRoot: string, ref: string): Promise { +): Pick { const segment = safeWorkspaceRefSegment(workspaceId); return { openRef: `${REVIEW_REF_PREFIX}/${segment}/open`, baselineRef: `${REVIEW_REF_PREFIX}/${segment}/baseline`, + checkpointRefPrefix: `${REVIEW_REF_PREFIX}/${segment}/checkpoints`, }; } @@ -368,6 +380,7 @@ async function isKnownReviewCommit(gitRoot: string, reviewCommit: string): Promi for (const line of refs.split("\n")) { const [ref, commit] = line.split("\t"); if (!ref || !commit) continue; + if (commit === reviewCommit && ref.endsWith(`/checkpoints/${reviewCommit}`)) return true; const match = ref.match(/^refs\/devspace\/review\/(.+)\/(open|baseline)$/); if (!match) continue; diff --git a/src/workspace-activity-store.test.ts b/src/workspace-activity-store.test.ts deleted file mode 100644 index 8ff02434d..000000000 --- a/src/workspace-activity-store.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import test from "node:test"; -import { SqliteWorkspaceStore } from "./workspace-store.js"; -import { WorkspaceActivityStore } from "./workspace-activity-store.js"; - -test("workspace activity persists raw tool calls across store reopen", async (t) => { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-store-test-")); - const workspaces = new SqliteWorkspaceStore(stateDir); - workspaces.createSession({ id: "ws_test", root: "/tmp/project", mode: "checkout" }); - workspaces.close(); - - const first = new WorkspaceActivityStore(stateDir); - const callId = first.startCall({ - workspaceId: "ws_test", - conversationScopeId: "conversation-1", - requestId: "request-1", - toolName: "read", - arguments: { workspace_id: "ws_test", path: "README.md" }, - startedAt: "2026-09-11T00:00:00.000Z", - }); - first.finishCall(callId, { - workspaceId: "ws_test", - result: { structuredContent: { result: "hello" } }, - completedAt: "2026-09-11T00:00:00.010Z", - durationMs: 10, - }); - first.close(); - - const reopened = new WorkspaceActivityStore(stateDir); - t.after(async () => { - reopened.close(); - await rm(stateDir, { recursive: true, force: true }); - }); - - assert.deepEqual(reopened.listCalls({ workspaceId: "ws_test", limit: 10 }), [ - { - id: callId, - workspaceId: "ws_test", - conversationScopeId: "conversation-1", - requestId: "request-1", - toolName: "read", - arguments: { workspace_id: "ws_test", path: "README.md" }, - result: { structuredContent: { result: "hello" } }, - startedAt: "2026-09-11T00:00:00.000Z", - completedAt: "2026-09-11T00:00:00.010Z", - durationMs: 10, - }, - ]); - assert.deepEqual(reopened.listCallSummaries({ workspaceId: "ws_test", limit: 10 }), [ - { - id: callId, - workspaceId: "ws_test", - conversationScopeId: "conversation-1", - requestId: "request-1", - toolName: "read", - startedAt: "2026-09-11T00:00:00.000Z", - completedAt: "2026-09-11T00:00:00.010Z", - durationMs: 10, - }, - ]); -}); diff --git a/src/workspace-activity.test.ts b/src/workspace-activity.test.ts deleted file mode 100644 index 80abec643..000000000 --- a/src/workspace-activity.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; -import { groupWorkspaceToolCalls } from "./workspace-activity.js"; -import type { WorkspaceToolCallSummary } from "./workspace-activity-store.js"; - -test("show_changes closes one review-backed activity group", () => { - const calls = [ - call(1, "read", "2026-09-11T00:00:00.000Z"), - call(2, "exec_command", "2026-09-11T00:00:01.000Z"), - call(3, "apply_patch", "2026-09-11T00:00:02.000Z"), - { ...call(4, "show_changes", "2026-09-11T00:00:03.000Z"), reviewRef: "abc" }, - call(5, "read", "2026-09-11T00:00:04.000Z"), - ]; - - const groups = groupWorkspaceToolCalls(calls); - assert.equal(groups.length, 2); - assert.equal(groups[1]?.id, "review:abc"); - assert.equal(groups[1]?.kind, "review"); - assert.deepEqual(groups[1]?.calls.map((entry) => entry.toolName), [ - "read", - "exec_command", - "apply_patch", - "show_changes", - ]); - assert.equal(groups[0]?.id, "activity:5"); -}); - -test("read-only activity uses inactivity gaps without mixing conversations", () => { - const groups = groupWorkspaceToolCalls([ - call(1, "read", "2026-09-11T00:00:00.000Z", "conversation-a"), - call(2, "read", "2026-09-11T00:00:30.000Z", "conversation-b"), - call(3, "read", "2026-09-11T00:03:00.000Z", "conversation-a"), - ]); - - assert.equal(groups.length, 3); - assert.deepEqual(groups.map((group) => group.calls.map((entry) => entry.id)), [[3], [2], [1]]); - assert.ok(groups.every((group) => group.kind === "inferred")); -}); - -function call( - id: number, - toolName: string, - startedAt: string, - conversationScopeId = "conversation-a", -): WorkspaceToolCallSummary { - return { - id, - workspaceId: "ws_test", - conversationScopeId, - toolName, - startedAt, - completedAt: startedAt, - durationMs: 0, - }; -} From 83ba0cc42e223c02a12644bc6ac37841377a7a96 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:17:28 +0530 Subject: [PATCH 6/9] feat(activity): store tool calls in workspace turns Co-Authored-By: Claude Opus 5.5 --- src/db/migrations.ts | 48 +++-- src/db/schema.ts | 44 ++-- src/oauth-store.test.ts | 1 + src/workspace-activity-store.ts | 353 +++++++++++++++++++++----------- 4 files changed, 299 insertions(+), 147 deletions(-) diff --git a/src/db/migrations.ts b/src/db/migrations.ts index cd98035d0..9b2fa4dda 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -49,8 +49,8 @@ const migrations: Migration[] = [ }, { version: 9, - name: "workspace-tool-calls", - up: migrateWorkspaceToolCalls, + name: "workspace-activity", + up: migrateWorkspaceActivity, }, ]; @@ -294,31 +294,49 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void { `); } -function migrateWorkspaceToolCalls(sqlite: Database.Database): void { +function migrateWorkspaceActivity(sqlite: Database.Database): void { sqlite.exec(` - create table if not exists workspace_tool_calls ( + create table if not exists workspace_turns ( id integer primary key autoincrement, - workspace_session_id text, + workspace_session_id text not null, + sequence integer not null, + status text not null default 'open', + review_ref text, + started_at text not null, + closed_at text, + foreign key (workspace_session_id) + references workspace_sessions(id) + on delete cascade + ); + + create unique index if not exists workspace_turns_sequence_idx + on workspace_turns(workspace_session_id, sequence); + + create unique index if not exists workspace_turns_open_idx + on workspace_turns(workspace_session_id) + where status = 'open'; + + create table if not exists workspace_turn_tool_calls ( + id integer primary key autoincrement, + turn_id integer not null, + origin text not null default 'model', conversation_scope_id text, request_id text, tool_name text not null, arguments_json text not null, result_json text, error_json text, + is_error integer not null default 0, started_at text not null, - completed_at text, - duration_ms integer, - review_ref text, - foreign key (workspace_session_id) - references workspace_sessions(id) + completed_at text not null, + duration_ms integer not null, + foreign key (turn_id) + references workspace_turns(id) on delete cascade ); - create index if not exists workspace_tool_calls_workspace_idx - on workspace_tool_calls(workspace_session_id, id desc); - - create index if not exists workspace_tool_calls_review_ref_idx - on workspace_tool_calls(workspace_session_id, review_ref); + create index if not exists workspace_turn_tool_calls_turn_idx + on workspace_turn_tool_calls(turn_id, started_at); `); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 2ef93087a..abe0064df 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { index, integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { index, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; export const workspaceSessions = sqliteTable( "workspace_sessions", @@ -56,27 +56,45 @@ export const workspaceConversationBindings = sqliteTable( ], ); -export const workspaceToolCalls = sqliteTable( - "workspace_tool_calls", +export const workspaceTurns = sqliteTable( + "workspace_turns", { id: integer("id").primaryKey({ autoIncrement: true }), - workspaceSessionId: text("workspace_session_id").references(() => workspaceSessions.id, { - onDelete: "cascade", - }), + workspaceSessionId: text("workspace_session_id") + .notNull() + .references(() => workspaceSessions.id, { onDelete: "cascade" }), + sequence: integer("sequence").notNull(), + status: text("status").notNull().default("open"), + reviewRef: text("review_ref"), + startedAt: text("started_at").notNull(), + closedAt: text("closed_at"), + }, + (table) => [ + uniqueIndex("workspace_turns_sequence_idx").on(table.workspaceSessionId, table.sequence), + ], +); + +export const workspaceTurnToolCalls = sqliteTable( + "workspace_turn_tool_calls", + { + id: integer("id").primaryKey({ autoIncrement: true }), + turnId: integer("turn_id") + .notNull() + .references(() => workspaceTurns.id, { onDelete: "cascade" }), + origin: text("origin").notNull().default("model"), conversationScopeId: text("conversation_scope_id"), requestId: text("request_id"), toolName: text("tool_name").notNull(), argumentsJson: text("arguments_json").notNull(), resultJson: text("result_json"), errorJson: text("error_json"), + isError: integer("is_error").notNull().default(0), startedAt: text("started_at").notNull(), - completedAt: text("completed_at"), - durationMs: integer("duration_ms"), - reviewRef: text("review_ref"), + completedAt: text("completed_at").notNull(), + durationMs: integer("duration_ms").notNull(), }, (table) => [ - index("workspace_tool_calls_workspace_idx").on(table.workspaceSessionId, table.id), - index("workspace_tool_calls_review_ref_idx").on(table.workspaceSessionId, table.reviewRef), + index("workspace_turn_tool_calls_turn_idx").on(table.turnId, table.startedAt), ], ); @@ -147,7 +165,7 @@ export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect; export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert; export type WorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferSelect; export type NewWorkspaceConversationBindingRow = typeof workspaceConversationBindings.$inferInsert; -export type WorkspaceToolCallRow = typeof workspaceToolCalls.$inferSelect; -export type NewWorkspaceToolCallRow = typeof workspaceToolCalls.$inferInsert; +export type WorkspaceTurnRow = typeof workspaceTurns.$inferSelect; +export type WorkspaceTurnToolCallRow = typeof workspaceTurnToolCalls.$inferSelect; export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect; export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert; diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 90a8ade25..565d6786e 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -52,6 +52,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise { { version: 6, name: "local-agent-effort-rename" }, { version: 7, name: "workspace-recovery-state" }, { version: 8, name: "local-agent-turns" }, + { version: 9, name: "workspace-activity" }, ]); } finally { database.close(); diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts index 799d13afc..f3a632d98 100644 --- a/src/workspace-activity-store.ts +++ b/src/workspace-activity-store.ts @@ -1,43 +1,79 @@ -import { and, desc, eq, lt } from "drizzle-orm"; +import { and, asc, count, desc, eq, max, sql } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; -import { workspaceSessions, workspaceToolCalls, type WorkspaceToolCallRow } from "./db/schema.js"; +import { + workspaceSessions, + workspaceTurns, + workspaceTurnToolCalls, + type WorkspaceSessionRow, + type WorkspaceTurnRow, + type WorkspaceTurnToolCallRow, +} from "./db/schema.js"; +import type { WorkspaceMode, WorkspaceStatus } from "./workspace-store.js"; -export interface WorkspaceToolCallSummary { +export type WorkspaceTurnStatus = "open" | "closed"; +export type WorkspaceToolCallOrigin = "model" | "card"; + +export interface ActivityWorkspaceRecord { + workspaceId: string; + root: string; + mode: WorkspaceMode; + status: WorkspaceStatus; + sourceRoot?: string; + createdAt: string; + lastUsedAt: string; + lastActivityAt?: string; + turns: number; +} + +export interface WorkspaceTurn { id: number; - workspaceId?: string; - conversationScopeId?: string; - requestId?: string; - toolName: string; - startedAt: string; - completedAt?: string; - durationMs?: number; + workspaceId: string; + /** 1-based position of the turn within its workspace. */ + sequence: number; + status: WorkspaceTurnStatus; + /** Review checkpoint that closed the turn; absent while the turn is open. */ reviewRef?: string; + startedAt: string; + closedAt?: string; + toolCalls: number; + failedToolCalls: number; } -export interface WorkspaceToolCall extends WorkspaceToolCallSummary { +export interface WorkspaceToolCall { + id: number; + turnId: number; + origin: WorkspaceToolCallOrigin; + conversationScopeId?: string; + requestId?: string; + toolName: string; arguments: unknown; result?: unknown; error?: unknown; + isError: boolean; + startedAt: string; + completedAt: string; + durationMs: number; } -export interface StartWorkspaceToolCall { - workspaceId?: string; +export interface RecordWorkspaceToolCall { + turnId: number; + origin: WorkspaceToolCallOrigin; conversationScopeId?: string; requestId?: string; toolName: string; - arguments: unknown; + argumentsJson: string; + resultJson?: string; + errorJson?: string; + isError: boolean; startedAt: string; -} - -export interface FinishWorkspaceToolCall { - workspaceId?: string; - result?: unknown; - error?: unknown; completedAt: string; durationMs: number; - reviewRef?: string; } +/** + * SQLite persistence for workspace turns and the tool calls made in them. + * A workspace has at most one open turn; a review checkpoint closes it. + */ export class WorkspaceActivityStore { private readonly database: DatabaseHandle; @@ -45,146 +81,225 @@ export class WorkspaceActivityStore { this.database = openDatabase(stateDir); } - startCall(input: StartWorkspaceToolCall): number { + hasWorkspace(workspaceId: string): boolean { + return this.database.db + .select({ id: workspaceSessions.id }) + .from(workspaceSessions) + .where(eq(workspaceSessions.id, workspaceId)) + .get() !== undefined; + } + + /** Returns the workspace's open turn, starting the next one when none is open. */ + openTurn(workspaceId: string, now = new Date()): WorkspaceTurn { + const open = this.database.sqlite.transaction(() => { + const existing = this.findOpenTurnRow(workspaceId); + if (existing) return existing; + + const previous = this.database.db + .select({ sequence: max(workspaceTurns.sequence) }) + .from(workspaceTurns) + .where(eq(workspaceTurns.workspaceSessionId, workspaceId)) + .get(); + return this.database.db + .insert(workspaceTurns) + .values({ + workspaceSessionId: workspaceId, + sequence: (previous?.sequence ?? 0) + 1, + status: "open", + startedAt: now.toISOString(), + }) + .returning() + .get(); + }); + return this.withCallCounts(open.immediate()); + } + + currentTurn(workspaceId: string): WorkspaceTurn | undefined { + const row = this.findOpenTurnRow(workspaceId); + return row ? this.withCallCounts(row) : undefined; + } + + /** Closes the open turn with its review checkpoint and returns it. */ + closeTurn(workspaceId: string, reviewRef: string, now = new Date()): WorkspaceTurn { + const turn = this.openTurn(workspaceId, now); + this.database.db + .update(workspaceTurns) + .set({ status: "closed", reviewRef, closedAt: now.toISOString() }) + .where(eq(workspaceTurns.id, turn.id)) + .run(); + return { ...turn, status: "closed", reviewRef, closedAt: now.toISOString() }; + } + + recordToolCall(input: RecordWorkspaceToolCall): number { const inserted = this.database.db - .insert(workspaceToolCalls) + .insert(workspaceTurnToolCalls) .values({ - workspaceSessionId: this.existingWorkspaceId(input.workspaceId) ?? null, + turnId: input.turnId, + origin: input.origin, conversationScopeId: input.conversationScopeId ?? null, requestId: input.requestId ?? null, toolName: input.toolName, - argumentsJson: JSON.stringify(input.arguments), + argumentsJson: input.argumentsJson, + resultJson: input.resultJson ?? null, + errorJson: input.errorJson ?? null, + isError: input.isError ? 1 : 0, startedAt: input.startedAt, - }) - .run(); - - return Number(inserted.lastInsertRowid); - } - - finishCall(id: number, input: FinishWorkspaceToolCall): void { - this.database.db - .update(workspaceToolCalls) - .set({ - ...(input.workspaceId - ? { workspaceSessionId: this.existingWorkspaceId(input.workspaceId) ?? null } - : {}), - resultJson: input.result === undefined ? null : JSON.stringify(input.result), - errorJson: input.error === undefined ? null : JSON.stringify(input.error), completedAt: input.completedAt, durationMs: input.durationMs, - reviewRef: input.reviewRef ?? null, }) - .where(eq(workspaceToolCalls.id, id)) .run(); + return Number(inserted.lastInsertRowid); } - listCalls(input: { - workspaceId: string; - beforeId?: number; - limit: number; - }): WorkspaceToolCall[] { - const clauses = [eq(workspaceToolCalls.workspaceSessionId, input.workspaceId)]; - if (input.beforeId !== undefined) clauses.push(lt(workspaceToolCalls.id, input.beforeId)); + /** Lists workspaces, most recently active first. */ + listWorkspaces(): ActivityWorkspaceRecord[] { + return this.workspaceQuery().all().map(rowToWorkspace); + } - return this.database.db - .select() - .from(workspaceToolCalls) - .where(and(...clauses)) - .orderBy(desc(workspaceToolCalls.id)) - .limit(input.limit) - .all() - .map(rowToWorkspaceToolCall); + getWorkspace(workspaceId: string): ActivityWorkspaceRecord | undefined { + const row = this.workspaceQuery().where(eq(workspaceSessions.id, workspaceId)).get(); + return row ? rowToWorkspace(row) : undefined; } - listCallSummaries(input: { - workspaceId: string; - beforeId?: number; - limit: number; - }): WorkspaceToolCallSummary[] { - const clauses = [eq(workspaceToolCalls.workspaceSessionId, input.workspaceId)]; - if (input.beforeId !== undefined) clauses.push(lt(workspaceToolCalls.id, input.beforeId)); + private workspaceQuery() { + const activity = this.database.db + .select({ + workspaceId: workspaceTurns.workspaceSessionId, + turns: sql`count(distinct ${workspaceTurns.id})`.as("turns"), + lastActivityAt: max(workspaceTurnToolCalls.completedAt).as("last_activity_at"), + }) + .from(workspaceTurns) + .leftJoin(workspaceTurnToolCalls, eq(workspaceTurnToolCalls.turnId, workspaceTurns.id)) + .groupBy(workspaceTurns.workspaceSessionId) + .as("activity"); return this.database.db .select({ - id: workspaceToolCalls.id, - workspaceSessionId: workspaceToolCalls.workspaceSessionId, - conversationScopeId: workspaceToolCalls.conversationScopeId, - requestId: workspaceToolCalls.requestId, - toolName: workspaceToolCalls.toolName, - startedAt: workspaceToolCalls.startedAt, - completedAt: workspaceToolCalls.completedAt, - durationMs: workspaceToolCalls.durationMs, - reviewRef: workspaceToolCalls.reviewRef, + session: workspaceSessions, + turns: activity.turns, + lastActivityAt: activity.lastActivityAt, }) - .from(workspaceToolCalls) - .where(and(...clauses)) - .orderBy(desc(workspaceToolCalls.id)) - .limit(input.limit) + .from(workspaceSessions) + .leftJoin(activity, eq(activity.workspaceId, workspaceSessions.id)) + .orderBy(desc(sql`coalesce(${activity.lastActivityAt}, ${workspaceSessions.lastUsedAt})`)) + .$dynamic(); + } + + /** Lists a workspace's turns, latest first. */ + listTurns(workspaceId: string): WorkspaceTurn[] { + return this.turnQuery() + .where(eq(workspaceTurns.workspaceSessionId, workspaceId)) + .orderBy(desc(workspaceTurns.sequence)) .all() - .map(rowToWorkspaceToolCallSummary); + .map(rowToTurn); } - getCall(workspaceId: string, callId: number): WorkspaceToolCall | undefined { - const row = this.database.db - .select() - .from(workspaceToolCalls) - .where( - and( - eq(workspaceToolCalls.workspaceSessionId, workspaceId), - eq(workspaceToolCalls.id, callId), - ), - ) + getTurn(workspaceId: string, sequence: number): WorkspaceTurn | undefined { + const row = this.turnQuery() + .where(and( + eq(workspaceTurns.workspaceSessionId, workspaceId), + eq(workspaceTurns.sequence, sequence), + )) .get(); - return row ? rowToWorkspaceToolCall(row) : undefined; + return row ? rowToTurn(row) : undefined; + } + + /** Lists the tool calls of one turn in the order they started. */ + listToolCalls(turnId: number): WorkspaceToolCall[] { + return this.database.db + .select() + .from(workspaceTurnToolCalls) + .where(eq(workspaceTurnToolCalls.turnId, turnId)) + .orderBy(asc(workspaceTurnToolCalls.startedAt), asc(workspaceTurnToolCalls.id)) + .all() + .map(rowToToolCall); } close(): void { this.database.close(); } - private existingWorkspaceId(workspaceId: string | undefined): string | undefined { - if (!workspaceId) return undefined; - const row = this.database.db - .select({ id: workspaceSessions.id }) - .from(workspaceSessions) - .where(eq(workspaceSessions.id, workspaceId)) + private findOpenTurnRow(workspaceId: string): WorkspaceTurnRow | undefined { + return this.database.db + .select() + .from(workspaceTurns) + .where(and( + eq(workspaceTurns.workspaceSessionId, workspaceId), + eq(workspaceTurns.status, "open"), + )) .get(); - return row?.id; + } + + private withCallCounts(row: WorkspaceTurnRow): WorkspaceTurn { + const counts = this.turnQuery().where(eq(workspaceTurns.id, row.id)).get(); + return counts ? rowToTurn(counts) : rowToTurn({ turn: row, toolCalls: 0, failedToolCalls: 0 }); + } + + private turnQuery() { + return this.database.db + .select({ + turn: workspaceTurns, + toolCalls: count(workspaceTurnToolCalls.id), + failedToolCalls: sql`coalesce(sum(${workspaceTurnToolCalls.isError}), 0)`, + }) + .from(workspaceTurns) + .leftJoin(workspaceTurnToolCalls, eq(workspaceTurnToolCalls.turnId, workspaceTurns.id)) + .groupBy(workspaceTurns.id) + .$dynamic(); } } -function rowToWorkspaceToolCall(row: WorkspaceToolCallRow): WorkspaceToolCall { +function rowToWorkspace({ session, turns, lastActivityAt }: { + session: WorkspaceSessionRow; + turns: number | null; + lastActivityAt: string | null; +}): ActivityWorkspaceRecord { return { - ...rowToWorkspaceToolCallSummary(row), - arguments: JSON.parse(row.argumentsJson) as unknown, - ...(row.resultJson ? { result: JSON.parse(row.resultJson) as unknown } : {}), - ...(row.errorJson ? { error: JSON.parse(row.errorJson) as unknown } : {}), + workspaceId: session.id, + root: session.root, + mode: session.mode as WorkspaceMode, + status: session.status as WorkspaceStatus, + ...(session.sourceRoot ? { sourceRoot: session.sourceRoot } : {}), + createdAt: session.createdAt, + lastUsedAt: session.lastUsedAt, + ...(lastActivityAt ? { lastActivityAt } : {}), + turns: turns ?? 0, + }; +} + +function rowToTurn(row: { + turn: WorkspaceTurnRow; + toolCalls: number; + failedToolCalls: number; +}): WorkspaceTurn { + const { turn } = row; + return { + id: turn.id, + workspaceId: turn.workspaceSessionId, + sequence: turn.sequence, + status: turn.status as WorkspaceTurnStatus, + ...(turn.reviewRef ? { reviewRef: turn.reviewRef } : {}), + startedAt: turn.startedAt, + ...(turn.closedAt ? { closedAt: turn.closedAt } : {}), + toolCalls: row.toolCalls, + failedToolCalls: Number(row.failedToolCalls), }; } -function rowToWorkspaceToolCallSummary( - row: Pick< - WorkspaceToolCallRow, - | "id" - | "workspaceSessionId" - | "conversationScopeId" - | "requestId" - | "toolName" - | "startedAt" - | "completedAt" - | "durationMs" - | "reviewRef" - >, -): WorkspaceToolCallSummary { +function rowToToolCall(row: WorkspaceTurnToolCallRow): WorkspaceToolCall { return { id: row.id, - ...(row.workspaceSessionId ? { workspaceId: row.workspaceSessionId } : {}), + turnId: row.turnId, + origin: row.origin as WorkspaceToolCallOrigin, ...(row.conversationScopeId ? { conversationScopeId: row.conversationScopeId } : {}), ...(row.requestId ? { requestId: row.requestId } : {}), toolName: row.toolName, + arguments: JSON.parse(row.argumentsJson) as unknown, + ...(row.resultJson ? { result: JSON.parse(row.resultJson) as unknown } : {}), + ...(row.errorJson ? { error: JSON.parse(row.errorJson) as unknown } : {}), + isError: row.isError === 1, startedAt: row.startedAt, - ...(row.completedAt ? { completedAt: row.completedAt } : {}), - ...(row.durationMs !== null ? { durationMs: row.durationMs } : {}), - ...(row.reviewRef ? { reviewRef: row.reviewRef } : {}), + completedAt: row.completedAt, + durationMs: row.durationMs, }; } From 7725de2997b92b8b4965f0d0f2e4bd94467b6b66 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:17:28 +0530 Subject: [PATCH 7/9] feat(activity): journal turns and expose a turn read API Co-Authored-By: Claude Opus 5.5 --- src/server.test.ts | 100 ++++++----- src/server.ts | 6 +- src/workspace-activity-journal.test.ts | 165 +++++++++++++----- src/workspace-activity-journal.ts | 232 ++++++++++++++++++------- src/workspace-activity.ts | 148 ++++++++-------- 5 files changed, 429 insertions(+), 222 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 543047fdb..0a21004c3 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -18,7 +18,12 @@ import { createMcpServer, createServer } from "./server.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; import { WorkspaceRegistry } from "./workspaces.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; -import { groupWorkspaceToolCalls } from "./workspace-activity.js"; +import { + listActivityWorkspaces, + listTurnToolCalls, + listWorkspaceTurns, + readTurnDiff, +} from "./workspace-activity.js"; import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; import { WorkspaceActivityStore } from "./workspace-activity-store.js"; @@ -315,57 +320,73 @@ test("show_changes can reopen a historical review without advancing the checkpoi ); }); -test("workspace activity groups a real tool sequence under its review ref", async (t) => { +test("workspace activity records turns between review checkpoints", async (t) => { const context = await fixture(t, { git: true, captureActivity: true }); - assert.ok(context.activity); + const activity = context.activity!; const workspaceId = structuredContent( - await callOpen(context.client, context.project, "activity-sequence"), - ).workspace_id; - assert.ok(typeof workspaceId === "string"); - - await context.client.callTool({ - name: "read", - arguments: { workspace_id: workspaceId, path: "README.md" }, - }); - await context.client.callTool({ - name: "exec_command", - arguments: { workspace_id: workspaceId, cmd: "printf activity" }, - }); - await context.client.callTool({ + await callOpen(context.client, context.project, "activity-turns"), + ).workspace_id as string; + const patch = (from: string, to: string) => context.client.callTool({ name: "apply_patch", arguments: { workspace_id: workspaceId, - patch: "*** Begin Patch\n*** Update File: README.md\n@@\n-hello\n+hello activity\n*** End Patch", + patch: `*** Begin Patch\n*** Update File: README.md\n@@\n-${from}\n+${to}\n*** End Patch`, }, }); - const shown = structuredContent(await context.client.callTool({ + const showChanges = async () => structuredContent(await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, - })); - const reviewRef = shown.review_ref; - assert.ok(typeof reviewRef === "string"); - - const groups = groupWorkspaceToolCalls( - context.activity.listCalls({ workspaceId, limit: 20 }), - ); - const reviewed = groups.find((group) => group.reviewRef === reviewRef); - assert.ok(reviewed); - assert.deepEqual(reviewed.calls.map((call) => call.toolName), [ - "read", - "exec_command", - "apply_patch", - "show_changes", - ]); + })).review_ref as string; + await patch("hello", "hello turn one"); + const firstRef = await showChanges(); + await patch("hello turn one", "hello turn two"); + await context.client.callTool({ + name: "exec_command", + arguments: { workspace_id: workspaceId, cmd: "printf activity" }, + }); + const secondRef = await showChanges(); await context.client.callTool({ name: "show_changes", arguments: { workspace_id: workspaceId }, - _meta: { "devspace/reviewRef": reviewRef }, + _meta: { "devspace/reviewRef": firstRef }, } as Parameters[0]); - assert.equal( - context.activity.listCalls({ workspaceId, limit: 20 }).filter((call) => call.toolName === "show_changes").length, - 1, + + const turns = listWorkspaceTurns(activity, workspaceId); + assert.deepEqual( + turns.map(({ sequence, status, reviewRef }) => ({ sequence, status, reviewRef })), + [ + { sequence: 3, status: "open", reviewRef: undefined }, + { sequence: 2, status: "closed", reviewRef: secondRef }, + { sequence: 1, status: "closed", reviewRef: firstRef }, + ], + ); + assert.deepEqual( + listTurnToolCalls(activity, workspaceId, 1)?.map((call) => call.toolName), + ["open_workspace", "apply_patch", "show_changes"], ); + assert.deepEqual( + listTurnToolCalls(activity, workspaceId, 2)?.map((call) => call.toolName), + ["apply_patch", "exec_command", "show_changes"], + ); + assert.equal(listTurnToolCalls(activity, workspaceId, 3)?.[0]?.origin, "card"); + + const secondDiff = await readTurnDiff(activity, workspaceId, 2); + assert.equal(secondDiff?.status, "available"); + if (secondDiff?.status === "available") { + assert.match(secondDiff.review.patch, /-hello turn one\n\+hello turn two/); + assert.doesNotMatch(secondDiff.review.patch, /^-hello$/m); + } + assert.deepEqual(await readTurnDiff(activity, workspaceId, 3), { status: "open" }); + + const pinned = await execFileAsync("git", ["for-each-ref", "--format=%(refname)", "refs/devspace/review"], { + cwd: context.project, + }); + for (const ref of [firstRef, secondRef]) assert.match(pinned.stdout, new RegExp(`/checkpoints/${ref}$`, "m")); + + const [listed] = await listActivityWorkspaces(activity); + assert.equal(listed?.workspaceId, workspaceId); + assert.equal(listed?.turns, 3); }); test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { @@ -849,12 +870,10 @@ async function fixture( resolveProviderAvailability(), ); const store = new SqliteWorkspaceStore(stateDir); - const activityJournal = options.captureActivity - ? new WorkspaceActivityJournal(stateDir) - : undefined; const activity = options.captureActivity ? new WorkspaceActivityStore(stateDir) : undefined; + const activityJournal = activity ? new WorkspaceActivityJournal(activity) : undefined; const workspaces = new WorkspaceRegistry(config, store); const server = createMcpServer( config, @@ -880,7 +899,6 @@ async function fixture( await client.close(); await server.close(); activity?.close(); - activityJournal?.close(); store.close(); }; diff --git a/src/server.ts b/src/server.ts index 06256e577..4dcefbf26 100644 --- a/src/server.ts +++ b/src/server.ts @@ -47,6 +47,7 @@ import { formatPathForPrompt } from "./skills.js"; import { DEVSPACE_VERSION } from "./version.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; +import { WorkspaceActivityStore } from "./workspace-activity-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { getLocalAgentProviderAvailabilitySnapshot, @@ -838,7 +839,8 @@ export function createServer( const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); const toolActivities = new ToolActivityTracker(); - const workspaceActivityJournal = new WorkspaceActivityJournal(config.stateDir, (error) => { + const workspaceActivity = new WorkspaceActivityStore(config.stateDir); + const workspaceActivityJournal = new WorkspaceActivityJournal(workspaceActivity, (error) => { logEvent(config.logging, "warn", "workspace_activity_journal_error", { error: error instanceof Error ? error.message : String(error), }); @@ -999,7 +1001,7 @@ export function createServer( }); } await toolActivities.waitForIdle(); - workspaceActivityJournal.close(); + workspaceActivity.close(); processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); diff --git a/src/workspace-activity-journal.test.ts b/src/workspace-activity-journal.test.ts index e84fd93ee..6ae95eeef 100644 --- a/src/workspace-activity-journal.test.ts +++ b/src/workspace-activity-journal.test.ts @@ -2,80 +2,149 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import test from "node:test"; +import test, { type TestContext } from "node:test"; import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; import { WorkspaceActivityStore } from "./workspace-activity-store.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; -test("journal associates open_workspace after the tool creates its workspace", async (t) => { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); - const workspaces = new SqliteWorkspaceStore(stateDir); - const journal = new WorkspaceActivityJournal(stateDir); - t.after(async () => { - journal.close(); - workspaces.close(); - await rm(stateDir, { recursive: true, force: true }); +const REVIEW_REF = "a".repeat(40); + +test("tool calls belong to the open turn until a model show_changes closes it", async (t) => { + const { journal, activity } = await journalFixture(t, ["ws_turns"]); + + await call(journal, "read", { workspace_id: "ws_turns", path: "README.md" }); + await call(journal, "show_changes", { workspace_id: "ws_turns" }, { + result: { structuredContent: { workspace_id: "ws_turns", review_ref: REVIEW_REF } }, }); + await call(journal, "exec_command", { workspace_id: "ws_turns", cmd: "ls" }); - await journal.capture({ - toolName: "open_workspace", - arguments: { path: "/tmp/project" }, - extra: { _meta: { "openai/session": "conversation-1" }, requestId: 17 }, - operation: async () => { + const [second, first] = activity.listTurns("ws_turns"); + assert.deepEqual( + { sequence: first?.sequence, status: first?.status, reviewRef: first?.reviewRef, calls: first?.toolCalls }, + { sequence: 1, status: "closed", reviewRef: REVIEW_REF, calls: 2 }, + ); + assert.deepEqual( + { sequence: second?.sequence, status: second?.status, calls: second?.toolCalls }, + { sequence: 2, status: "open", calls: 1 }, + ); + assert.deepEqual(activity.listToolCalls(first!.id).map((entry) => entry.toolName), ["read", "show_changes"]); +}); + +test("card replays are recorded without closing the turn", async (t) => { + const { journal, activity } = await journalFixture(t, ["ws_card"]); + + await call(journal, "show_changes", { workspace_id: "ws_card" }, { + meta: { "devspace/reviewRef": REVIEW_REF }, + result: { structuredContent: { workspace_id: "ws_card", review_ref: REVIEW_REF } }, + }); + + const [turn] = activity.listTurns("ws_card"); + assert.equal(turn?.status, "open"); + assert.equal(activity.listToolCalls(turn!.id)[0]?.origin, "card"); +}); + +test("open_workspace is recorded once its workspace exists, and failed opens leave nothing", async (t) => { + const { journal, activity, workspaces } = await journalFixture(t, []); + + await assert.rejects(call(journal, "open_workspace", { path: "/outside" }, { + error: new Error("Path is outside allowed roots"), + })); + await call(journal, "open_workspace", { path: "/tmp/project" }, { + meta: { "openai/session": "conversation-1" }, + result: () => { workspaces.createSession({ id: "ws_opened", root: "/tmp/project" }); return { structuredContent: { workspace_id: "ws_opened" } }; }, }); - const activity = new WorkspaceActivityStore(stateDir); - t.after(() => activity.close()); - const calls = activity.listCalls({ workspaceId: "ws_opened", limit: 10 }); - assert.equal(calls.length, 1); - assert.equal(calls[0]?.toolName, "open_workspace"); - assert.equal(calls[0]?.conversationScopeId, "conversation-1"); - assert.equal(calls[0]?.requestId, "17"); + assert.deepEqual(activity.listWorkspaces().map((workspace) => workspace.workspaceId), ["ws_opened"]); + const [turn] = activity.listTurns("ws_opened"); + const [opened] = activity.listToolCalls(turn!.id); + assert.equal(opened?.toolName, "open_workspace"); + assert.equal(opened?.conversationScopeId, "conversation-1"); + assert.equal(opened?.requestId, "7"); }); -test("journal ignores historical show_changes replays", async (t) => { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); - const workspaces = new SqliteWorkspaceStore(stateDir); - workspaces.createSession({ id: "ws_test", root: "/tmp/project" }); - const journal = new WorkspaceActivityJournal(stateDir); - t.after(async () => { - journal.close(); - workspaces.close(); - await rm(stateDir, { recursive: true, force: true }); - }); +test("journal redacts secrets, caps payloads, and keeps errors", async (t) => { + const { journal, activity } = await journalFixture(t, ["ws_redact"]); - await journal.capture({ - toolName: "show_changes", - arguments: { workspace_id: "ws_test" }, - extra: { _meta: { "devspace/reviewRef": "abc" } }, - operation: async () => ({ structuredContent: { workspace_id: "ws_test", review_ref: "abc" } }), + await call(journal, "exec_command", { + workspace_id: "ws_redact", + cmd: "curl -H 'Authorization: Bearer abcdefghijklmnopqrstuvwxyz' https://example.test", + env: { GITHUB_TOKEN: "ghp_shouldnotpersist", max_output_tokens: 100 }, + }, { + result: { content: [{ type: "text", text: "x".repeat(20_000) }], _meta: { card: { patch: "large" } } }, }); + await assert.rejects(call(journal, "read", { workspace_id: "ws_redact", path: "missing" }, { + error: new Error("ENOENT"), + })); - const activity = new WorkspaceActivityStore(stateDir); - t.after(() => activity.close()); - assert.deepEqual(activity.listCalls({ workspaceId: "ws_test", limit: 10 }), []); + const [turn] = activity.listTurns("ws_redact"); + const [exec, failed] = activity.listToolCalls(turn!.id); + const serialized = JSON.stringify(exec); + assert.doesNotMatch(serialized, /abcdefghijklmnop|ghp_should/); + assert.match(serialized, /\[redacted\]/); + assert.deepEqual((exec?.arguments as { env: unknown }).env, { GITHUB_TOKEN: "[redacted]", max_output_tokens: 100 }); + assert.ok(serialized.length < 10_000); + assert.equal(serialized.includes("_meta"), false); + assert.deepEqual( + { isError: failed?.isError, error: failed?.error }, + { isError: true, error: { name: "Error", message: "ENOENT" } }, + ); + assert.equal(turn?.failedToolCalls, 1); }); test("journal failures never replace the tool result", async (t) => { - const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); - const errors: unknown[] = []; - const journal = new WorkspaceActivityJournal(stateDir, (error) => errors.push(error)); - t.after(async () => { - journal.close(); - await rm(stateDir, { recursive: true, force: true }); + const { journal, activity } = await journalFixture(t, ["ws_closed"], () => { + throw new Error("reporter failed too"); }); - journal.close(); + activity.close(); const result = await journal.capture({ toolName: "read", - arguments: { workspace_id: "missing" }, + arguments: { workspace_id: "ws_closed" }, extra: {}, operation: async () => "tool result", }); assert.equal(result, "tool result"); - assert.equal(errors.length, 1); }); + +async function journalFixture( + t: TestContext, + workspaceIds: string[], + onError?: (error: unknown) => void, +) { + const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); + const workspaces = new SqliteWorkspaceStore(stateDir); + for (const id of workspaceIds) workspaces.createSession({ id, root: "/tmp/project" }); + const activity = new WorkspaceActivityStore(stateDir); + const journal = new WorkspaceActivityJournal(activity, onError); + t.after(async () => { + try { + activity.close(); + } catch { + // Some tests close the store early. + } + workspaces.close(); + await rm(stateDir, { recursive: true, force: true }); + }); + return { journal, activity, workspaces }; +} + +function call( + journal: WorkspaceActivityJournal, + toolName: string, + args: Record, + options: { meta?: Record; result?: unknown; error?: Error } = {}, +): Promise { + return journal.capture({ + toolName, + arguments: args, + extra: { _meta: options.meta, requestId: 7 }, + operation: async () => { + if (options.error) throw options.error; + return typeof options.result === "function" ? (options.result as () => unknown)() : options.result ?? {}; + }, + }); +} diff --git a/src/workspace-activity-journal.ts b/src/workspace-activity-journal.ts index 41e18f259..758d1fd08 100644 --- a/src/workspace-activity-journal.ts +++ b/src/workspace-activity-journal.ts @@ -1,20 +1,43 @@ import { conversationScopeIdFromRequestMeta } from "./request-meta.js"; -import { WorkspaceActivityStore } from "./workspace-activity-store.js"; +import type { + WorkspaceActivityStore, + WorkspaceToolCallOrigin, + WorkspaceTurn, +} from "./workspace-activity-store.js"; type ToolHandlerExtra = { _meta?: Record; requestId?: unknown; }; +const MAX_STRING_LENGTH = 8 * 1024; +const MAX_RECORD_LENGTH = 64 * 1024; +const REDACTED = "[redacted]"; +const SECRET_KEY_PATTERN = /(token|secret|password|passwd|authorization|cookie|credential|api[_-]?key|private[_-]?key)/i; +const SECRET_VALUE_PATTERNS: RegExp[] = [ + /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, + /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, + /\bgh[pousr]_[A-Za-z0-9]{20,}/g, + /\bgithub_pat_[A-Za-z0-9_]{20,}/g, + /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, + /\bAKIA[0-9A-Z]{16}\b/g, + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, +]; + +/** + * Records every registered MCP tool call into the open turn of its workspace. + * + * A turn is the work between two review checkpoints: a model-initiated + * show_changes closes the open turn with its review_ref, and the next call + * starts a new one. Calls are persisted only once their workspace exists, so a + * failed open_workspace leaves nothing behind. Journal failures are reported to + * `onError` and never change the tool outcome. + */ export class WorkspaceActivityJournal { - private readonly store: WorkspaceActivityStore; - constructor( - stateDir: string, + private readonly store: WorkspaceActivityStore, private readonly onError: (error: unknown) => void = () => {}, - ) { - this.store = new WorkspaceActivityStore(stateDir); - } + ) {} async capture(input: { toolName: string; @@ -22,69 +45,169 @@ export class WorkspaceActivityJournal { extra: ToolHandlerExtra; operation: () => Promise; }): Promise { - if (isHistoricalReviewReplay(input.toolName, input.extra._meta)) { - return input.operation(); - } - const startedAt = new Date(); const startedAtMs = performance.now(); const argumentWorkspaceId = workspaceIdFromValue(input.arguments); - let callId: number | undefined; - - try { - callId = this.store.startCall({ - workspaceId: argumentWorkspaceId, - conversationScopeId: conversationScopeIdFromRequestMeta(input.extra._meta), - requestId: requestIdFromExtra(input.extra.requestId), - toolName: input.toolName, - arguments: input.arguments, - startedAt: startedAt.toISOString(), - }); - } catch (error) { - this.onError(error); - } + // Calls belong to the turn that was open when they started. + const turnId = argumentWorkspaceId + ? this.safely(() => this.openTurnId(argumentWorkspaceId, startedAt)) + : undefined; + let result: T; try { - const result = await input.operation(); - if (callId !== undefined) { - this.finishSafely(callId, { - workspaceId: workspaceIdFromValue(result) ?? argumentWorkspaceId, - result, - completedAt: new Date().toISOString(), - durationMs: Math.round(performance.now() - startedAtMs), - reviewRef: reviewRefFromValue(result), - }); - } - return result; + result = await input.operation(); } catch (error) { - if (callId !== undefined) { - this.finishSafely(callId, { - workspaceId: argumentWorkspaceId, - error: serializeError(error), - completedAt: new Date().toISOString(), - durationMs: Math.round(performance.now() - startedAtMs), - }); - } + this.safely(() => this.record(input, { + workspaceId: argumentWorkspaceId, + turnId, + startedAt, + startedAtMs, + error: serializeError(error), + })); throw error; } + + this.safely(() => this.record(input, { + workspaceId: workspaceIdFromValue(result) ?? argumentWorkspaceId, + turnId, + startedAt, + startedAtMs, + result, + })); + return result; + } + + /** + * Hook point for subagent activity: the turn that events for `workspaceId` + * should attach to right now. Subagent events (#379–384) are not journaled + * yet; their app-owned turnId is a subagent turn, not this workspace turn. + */ + currentTurn(workspaceId: string): WorkspaceTurn | undefined { + return this.safely(() => this.store.currentTurn(workspaceId)); } - close(): void { - this.store.close(); + private openTurnId(workspaceId: string, now: Date): number | undefined { + return this.store.hasWorkspace(workspaceId) + ? this.store.openTurn(workspaceId, now).id + : undefined; } - private finishSafely( - callId: number, - input: Parameters[1], + private record( + input: { toolName: string; arguments: unknown; extra: ToolHandlerExtra }, + outcome: { + workspaceId?: string; + turnId?: number; + startedAt: Date; + startedAtMs: number; + result?: unknown; + error?: { name: string; message: string }; + }, ): void { + if (!outcome.workspaceId || !this.store.hasWorkspace(outcome.workspaceId)) return; + + const completedAt = new Date(); + const origin = toolCallOrigin(input.extra._meta); + const reviewRef = origin === "model" && input.toolName === "show_changes" && !outcome.error + ? reviewRefFromValue(outcome.result) + : undefined; + const turnId = reviewRef + ? this.store.closeTurn(outcome.workspaceId, reviewRef, completedAt).id + : outcome.turnId ?? this.store.openTurn(outcome.workspaceId, completedAt).id; + const resultSummary = outcome.result === undefined ? undefined : summarizeResult(outcome.result); + + this.store.recordToolCall({ + turnId, + origin, + conversationScopeId: conversationScopeIdFromRequestMeta(input.extra._meta), + requestId: requestIdFromExtra(input.extra.requestId), + toolName: input.toolName, + argumentsJson: journalJson(input.arguments ?? {}), + ...(resultSummary === undefined ? {} : { resultJson: journalJson(resultSummary) }), + ...(outcome.error ? { errorJson: journalJson(outcome.error) } : {}), + isError: outcome.error !== undefined || asRecord(outcome.result)?.isError === true, + startedAt: outcome.startedAt.toISOString(), + completedAt: completedAt.toISOString(), + durationMs: Math.round(performance.now() - outcome.startedAtMs), + }); + } + + private safely(operation: () => R): R | undefined { try { - this.store.finishCall(callId, input); + return operation(); } catch (error) { - this.onError(error); + try { + this.onError(error); + } catch { + // Error reporting must not affect the tool call either. + } + return undefined; } } } +/** Serializes a value for the journal with secrets redacted and size capped. */ +export function journalJson(value: unknown): string { + const json = JSON.stringify(redact(value, 0)) ?? "null"; + if (json.length <= MAX_RECORD_LENGTH) return json; + return JSON.stringify({ + truncated: true, + length: json.length, + preview: json.slice(0, MAX_STRING_LENGTH), + }); +} + +/** + * Keeps what a reader needs to understand a tool result: its text, structured + * content, and error flag. Card metadata (`_meta`) is dropped because it + * duplicates large payloads such as full review patches. + */ +function summarizeResult(result: unknown): unknown { + const record = asRecord(result); + if (!record || !Array.isArray(record.content)) return result; + + const text = record.content + .map((block) => asRecord(block)) + .filter((block) => block?.type === "text" && typeof block.text === "string") + .map((block) => block!.text as string) + .join("\n"); + return { + ...(text ? { text } : {}), + ...(record.structuredContent !== undefined ? { structuredContent: record.structuredContent } : {}), + ...(record.isError === true ? { isError: true } : {}), + }; +} + +function redact(value: unknown, depth: number): unknown { + if (typeof value === "string") return redactString(value); + if (value === null || typeof value !== "object") return value; + if (depth > 8) return "[nested]"; + if (Array.isArray(value)) return value.map((item) => redact(item, depth + 1)); + + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + key, + SECRET_KEY_PATTERN.test(key) && typeof item === "string" && item !== "" + ? REDACTED + : redact(item, depth + 1), + ]), + ); +} + +function redactString(value: string): string { + let redacted = value; + for (const pattern of SECRET_VALUE_PATTERNS) { + redacted = redacted.replace(pattern, REDACTED); + } + return redacted.length > MAX_STRING_LENGTH + ? `${redacted.slice(0, MAX_STRING_LENGTH)}… [truncated ${redacted.length - MAX_STRING_LENGTH} chars]` + : redacted; +} + +/** The workspace card replays historical reviews through show_changes with this marker. */ +function toolCallOrigin(meta: Record | undefined): WorkspaceToolCallOrigin { + return typeof meta?.["devspace/reviewRef"] === "string" ? "card" : "model"; +} + function workspaceIdFromValue(value: unknown): string | undefined { const record = asRecord(value); const direct = record?.workspace_id; @@ -105,13 +228,6 @@ function requestIdFromExtra(value: unknown): string | undefined { return typeof value === "string" || typeof value === "number" ? String(value) : undefined; } -function isHistoricalReviewReplay( - toolName: string, - meta: Record | undefined, -): boolean { - return toolName === "show_changes" && typeof meta?.["devspace/reviewRef"] === "string"; -} - function serializeError(error: unknown): { name: string; message: string } { if (error instanceof Error) { return { name: error.name, message: error.message }; diff --git a/src/workspace-activity.ts b/src/workspace-activity.ts index 31dbc6f2f..a62ca73a3 100644 --- a/src/workspace-activity.ts +++ b/src/workspace-activity.ts @@ -1,88 +1,90 @@ -import type { WorkspaceToolCallSummary } from "./workspace-activity-store.js"; +import { git } from "./git.js"; +import { readReviewRef, type ReviewChangesResult } from "./review-checkpoints.js"; +import type { + ActivityWorkspaceRecord, + WorkspaceActivityStore, + WorkspaceToolCall, + WorkspaceTurn, +} from "./workspace-activity-store.js"; -const ACTIVITY_GROUP_GAP_MS = 2 * 60 * 1000; +// Read API for workspace observability. A turn is the work between two review +// checkpoints: turn N holds the tool calls made after checkpoint N-1 and the +// diff from checkpoint N-1 to checkpoint N. -export interface WorkspaceActivityGroup { - id: string; - kind: "review" | "inferred"; - startedAt: string; - completedAt?: string; - reviewRef?: string; - calls: WorkspaceToolCallSummary[]; -} - -export function groupWorkspaceToolCalls( - calls: ReadonlyArray, -): WorkspaceActivityGroup[] { - const byConversation = new Map(); - for (const call of calls) { - const key = call.conversationScopeId ?? "__unscoped__"; - const conversationCalls = byConversation.get(key) ?? []; - conversationCalls.push(call); - byConversation.set(key, conversationCalls); - } +export type { WorkspaceToolCall, WorkspaceTurn } from "./workspace-activity-store.js"; - const groups = [...byConversation.values()].flatMap(groupConversationCalls); - return [...groups].sort((left, right) => { - const timeOrder = right.startedAt.localeCompare(left.startedAt); - if (timeOrder !== 0) return timeOrder; - return (right.calls[0]?.id ?? 0) - (left.calls[0]?.id ?? 0); - }); +export interface ActivityWorkspace extends ActivityWorkspaceRecord { + /** Current branch of the workspace root; absent when detached or unreadable. */ + branch?: string; } -function groupConversationCalls(calls: WorkspaceToolCallSummary[]): WorkspaceActivityGroup[] { - const ordered = [...calls].sort((left, right) => { - const timeOrder = left.startedAt.localeCompare(right.startedAt); - return timeOrder !== 0 ? timeOrder : left.id - right.id; - }); - const groups: WorkspaceActivityGroup[] = []; - let current: WorkspaceToolCallSummary[] = []; +export type TurnDiff = + | { status: "available"; review: ReviewChangesResult } + | { status: "open" } + | { status: "unavailable"; reason: string }; - const flush = () => { - if (current.length === 0) return; - groups.push(toActivityGroup(current)); - current = []; - }; - - for (const call of ordered) { - const previous = current.at(-1); - if (previous && callStartedAfterGap(previous, call)) flush(); +export async function listActivityWorkspaces( + store: WorkspaceActivityStore, +): Promise { + return Promise.all(store.listWorkspaces().map(withBranch)); +} - current.push(call); - if (isReviewBoundary(call)) flush(); - } +export async function getActivityWorkspace( + store: WorkspaceActivityStore, + workspaceId: string, +): Promise { + const workspace = store.getWorkspace(workspaceId); + return workspace ? withBranch(workspace) : undefined; +} - flush(); - return groups; +/** Lists a workspace's turns, latest first. */ +export function listWorkspaceTurns( + store: WorkspaceActivityStore, + workspaceId: string, +): WorkspaceTurn[] { + return store.listTurns(workspaceId); } -function toActivityGroup(calls: WorkspaceToolCallSummary[]): WorkspaceActivityGroup { - const first = calls[0]!; - const last = calls.at(-1)!; - const reviewRef = isReviewBoundary(last) ? last.reviewRef : undefined; - return { - id: reviewRef ? `review:${reviewRef}` : `activity:${first.id}`, - kind: reviewRef ? "review" : "inferred", - startedAt: first.startedAt, - ...(last.completedAt ? { completedAt: last.completedAt } : {}), - ...(reviewRef ? { reviewRef } : {}), - calls, - }; +/** Lists the tool calls of turn `sequence`, or undefined when the turn does not exist. */ +export function listTurnToolCalls( + store: WorkspaceActivityStore, + workspaceId: string, + sequence: number, +): WorkspaceToolCall[] | undefined { + const turn = store.getTurn(workspaceId, sequence); + return turn ? store.listToolCalls(turn.id) : undefined; } -function callStartedAfterGap( - previous: WorkspaceToolCallSummary, - next: WorkspaceToolCallSummary, -): boolean { - const previousEnd = Date.parse(previous.completedAt ?? previous.startedAt); - const nextStart = Date.parse(next.startedAt); - return Number.isFinite(previousEnd) - && Number.isFinite(nextStart) - && nextStart - previousEnd > ACTIVITY_GROUP_GAP_MS; +/** + * Reads the diff of turn `sequence`: checkpoint N-1 to checkpoint N. Each + * checkpoint snapshot is a commit whose parent is the previous checkpoint, so + * this is exactly the diff show_changes reported when it closed the turn. + */ +export async function readTurnDiff( + store: WorkspaceActivityStore, + workspaceId: string, + sequence: number, +): Promise { + const workspace = store.getWorkspace(workspaceId); + const turn = store.getTurn(workspaceId, sequence); + if (!workspace || !turn) return undefined; + if (!turn.reviewRef) return { status: "open" }; + + try { + return { status: "available", review: await readReviewRef(workspace.root, turn.reviewRef) }; + } catch (error) { + return { + status: "unavailable", + reason: error instanceof Error ? error.message : String(error), + }; + } } -function isReviewBoundary( - call: WorkspaceToolCallSummary, -): call is WorkspaceToolCallSummary & { reviewRef: string } { - return call.toolName === "show_changes" && typeof call.reviewRef === "string"; +async function withBranch(workspace: ActivityWorkspaceRecord): Promise { + try { + const branch = (await git(workspace.root, ["symbolic-ref", "--quiet", "--short", "HEAD"])).stdout.trim(); + return branch ? { ...workspace, branch } : workspace; + } catch { + return workspace; + } } From def7d4063db6c9585232bf69063065511ed3fdd0 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:43:12 +0530 Subject: [PATCH 8/9] fix(activity): redact credential assignments, headers and URLs without touching prose Basic/Bearer now match only in Authorization headers (or a long standalone Bearer token), so text like "Basic functionality" survives. Commands and outputs also redact ALL_CAPS secret env assignments, credential CLI flags and user:pass@ URLs. Co-Authored-By: Claude Sonnet 5.5 --- src/workspace-activity-journal.test.ts | 50 +++++++++++++++++++++++++- src/workspace-activity-journal.ts | 32 +++++++++++------ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/workspace-activity-journal.test.ts b/src/workspace-activity-journal.test.ts index 6ae95eeef..5428676fd 100644 --- a/src/workspace-activity-journal.test.ts +++ b/src/workspace-activity-journal.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test, { type TestContext } from "node:test"; -import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; +import { WorkspaceActivityJournal, journalJson } from "./workspace-activity-journal.js"; import { WorkspaceActivityStore } from "./workspace-activity-store.js"; import { SqliteWorkspaceStore } from "./workspace-store.js"; @@ -94,6 +94,54 @@ test("journal redacts secrets, caps payloads, and keeps errors", async (t) => { assert.equal(turn?.failedToolCalls, 1); }); +test("journal redacts credentials in commands and outputs", () => { + const redacted = (value: string): string => JSON.parse(journalJson(value)) as string; + const secrets = [ + "API_KEY=abc123def", + "export GITHUB_TOKEN=ghx_notavendorshape", + 'DB_PASSWORD="hunter two"', + "AWS_SECRET_ACCESS_KEY='abc/def+ghi'", + "curl --password hunter2 https://example.test", + "docker login --password=hunter2", + "tool --api-key abc123", + "curl -H 'Authorization: Bearer abc.def' https://example.test", + 'curl -H "authorization: basic dXNlcjpwYXNzd29yZA==" https://example.test', + "Authorization: Basic dXNlcjpwYXNzd29yZA==", + "token Bearer abcdefghijklmnopqrstuvwxyz0123", + "git clone https://user:pass@github.com/acme/repo.git", + "postgres://app:s3cret@db.internal:5432/app", + ]; + for (const secret of secrets) { + const output = redacted(`run: ${secret}`); + assert.match(output, /\[redacted\]/, secret); + assert.doesNotMatch(output, /abc123|hunter|ghx_|abc\/def|abc\.def|dXNlcj|s3cret|:pass@|abcdefghij/, secret); + } + + assert.equal(redacted("export API_KEY=abc123 && run"), "export API_KEY=[redacted] && run"); + assert.equal(redacted("curl https://u:p@host/x"), "curl https://[redacted]@host/x"); +}); + +test("journal leaves ordinary prose and code alone", () => { + const unchanged = [ + "Basic functionality and basic configuration of the internationalization layer", + "Authorization: required for write access", + "bearer authentication is supported", + "MAX_TOKENS=4096", + "TOKEN_LIMIT=100 npm test", + "export TOKEN=$(cat token.txt)", + "API_KEY=$API_KEY node server.js", + "docker login --password-stdin", + "const tokenCount = 5; // password: string", + "git clone https://github.com/acme/repo.git", + "git clone ssh://git@github.com/acme/repo.git", + "open http://localhost:3000/@user", + "curl https://example.com:8080/path", + ]; + for (const text of unchanged) { + assert.equal(JSON.parse(journalJson(text)), text); + } +}); + test("journal failures never replace the tool result", async (t) => { const { journal, activity } = await journalFixture(t, ["ws_closed"], () => { throw new Error("reporter failed too"); diff --git a/src/workspace-activity-journal.ts b/src/workspace-activity-journal.ts index 758d1fd08..2d7f5645a 100644 --- a/src/workspace-activity-journal.ts +++ b/src/workspace-activity-journal.ts @@ -14,14 +14,26 @@ const MAX_STRING_LENGTH = 8 * 1024; const MAX_RECORD_LENGTH = 64 * 1024; const REDACTED = "[redacted]"; const SECRET_KEY_PATTERN = /(token|secret|password|passwd|authorization|cookie|credential|api[_-]?key|private[_-]?key)/i; -const SECRET_VALUE_PATTERNS: RegExp[] = [ - /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, - /\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, - /\bgh[pousr]_[A-Za-z0-9]{20,}/g, - /\bgithub_pat_[A-Za-z0-9_]{20,}/g, - /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, - /\bAKIA[0-9A-Z]{16}\b/g, - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, +// Value patterns are deliberately shape- or context-bound (an Authorization +// header, an env-var name, a credential flag) so ordinary prose and source code +// such as "Basic functionality" or `MAX_TOKENS=4096` survive intact. A pattern's +// replacement may keep its leading context via $1. +const SECRET_VALUE_PATTERNS: Array<[RegExp, string]> = [ + [/\b(Authorization["']?\s*[:=]\s*["']?)(?:Bearer|Basic|Token|Digest)\s+[^\s"']+/gi, `$1${REDACTED}`], + [/\bBearer\s+[A-Za-z0-9._~+/=-]{20,}/g, `Bearer ${REDACTED}`], + // ALL_CAPS env assignment whose name ends in a secret word: API_KEY=…, export GITHUB_TOKEN="…". + // MAX_TOKENS=… and values that defer to another variable ($VAR, $(cmd)) are left alone. + [/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|ACCESS_?KEY|SECRET_?KEY|PRIVATE_?KEY|CREDENTIALS?)=)(?!\$)(?:"[^"]*"|'[^']*'|[^\s"'`;&|)]+)/g, `$1${REDACTED}`], + // CLI credential flags: --password hunter2, --api-key=abc. --password-stdin takes no value. + [/(--(?:[a-z0-9]+-)*(?:password|passwd|token|secret|api-?key|access-?key|secret-?key|private-?key)(?:\s+|=))(?![-$])(?:"[^"]*"|'[^']*'|[^\s"'`;&|)]+)/gi, `$1${REDACTED}`], + // Credentials embedded in a URL: https://user:pass@host. + [/\b([a-z][a-z0-9+.-]*:\/\/)[^\s:@/?#]+:[^\s@/?#]+@/gi, `$1${REDACTED}@`], + [/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, REDACTED], + [/\bgh[pousr]_[A-Za-z0-9]{20,}/g, REDACTED], + [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, REDACTED], + [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, REDACTED], + [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED], + [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, REDACTED], ]; /** @@ -195,8 +207,8 @@ function redact(value: unknown, depth: number): unknown { function redactString(value: string): string { let redacted = value; - for (const pattern of SECRET_VALUE_PATTERNS) { - redacted = redacted.replace(pattern, REDACTED); + for (const [pattern, replacement] of SECRET_VALUE_PATTERNS) { + redacted = redacted.replace(pattern, replacement); } return redacted.length > MAX_STRING_LENGTH ? `${redacted.slice(0, MAX_STRING_LENGTH)}… [truncated ${redacted.length - MAX_STRING_LENGTH} chars]` From 514c5f48ab5722f2ed7f82bbfae1f0018d494f20 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:29:30 +0530 Subject: [PATCH 9/9] feat(activity): add activity.enabled and a per-workspace turn cap Recording stays on by default; activity.enabled turns it off and activity.maxTurns (200) prunes older closed turns and their tool calls. Co-Authored-By: Claude Opus 5.5 --- docs/configuration.md | 16 ++++++++++++++++ schema/v1/devspace.schema.json | 19 +++++++++++++++++++ src/config-schema.ts | 8 ++++++++ src/config.test.ts | 1 + src/config.ts | 2 ++ src/server.ts | 14 ++++++++------ src/workspace-activity-journal.test.ts | 16 +++++++++++++++- src/workspace-activity-store.ts | 17 ++++++++++++++++- 8 files changed, 85 insertions(+), 8 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..d07e3ef6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -43,6 +43,10 @@ Run `devspace init` to create both files. `devspace config set publicBaseUrl "ui": { "enabled": true, }, + "activity": { + "enabled": true, + "maxTurns": 200, + }, "artifacts": { "enabled": false, "maxFileBytes": 104857600, @@ -107,6 +111,18 @@ This avoids rendering an iframe for every read, edit, search, or command call. Setting `ui.enabled` to `false` removes the metadata but does not remove the `show_changes` tool. +## Workspace activity + +DevSpace records each workspace tool call, with its arguments and result, in the +local state database (`storage.stateDir`, readable only by your user). Calls are +grouped into turns that close at each review checkpoint. Strings are truncated +and common secret patterns are redacted, but file contents and command output +are kept as the tools returned them. + +`activity.maxTurns` (default 200) caps the closed turns kept per workspace; +older turns and their tool calls are deleted. Set `activity.enabled` to `false` +to stop recording; turns already recorded stay until their workspace is removed. + ## Skills and subagents DevSpace discovers standard Agent Skills from `~/.agents/skills`, project diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index cf9ce8f84..0159a530f 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -114,6 +114,25 @@ }, "additionalProperties": false }, + "activity": { + "default": {}, + "type": "object", + "properties": { + "enabled": { + "default": true, + "description": "Record workspace tool calls, with their arguments and results, in the local state database.", + "type": "boolean" + }, + "maxTurns": { + "default": 200, + "description": "Closed turns kept per workspace. Older turns and their tool calls are deleted.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, "artifacts": { "default": {}, "type": "object", diff --git a/src/config-schema.ts b/src/config-schema.ts index 95141fbd9..ed74b155b 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -35,6 +35,13 @@ const artifactsConfigSchema = z.object({ maxFileBytes: z.number().int().positive().default(100 * 1024 * 1024), }).strict().prefault({}); +const activityConfigSchema = z.object({ + enabled: z.boolean().default(true) + .describe("Record workspace tool calls, with their arguments and results, in the local state database."), + maxTurns: z.number().int().positive().default(200) + .describe("Closed turns kept per workspace. Older turns and their tool calls are deleted."), +}).strict().prefault({}); + const skillsConfigSchema = z.object({ enabled: z.boolean().default(true), paths: z.array(z.string().trim().min(1)).default([]), @@ -76,6 +83,7 @@ export const devspaceConfigSchema = z.object({ storage: storageConfigSchema, tools: toolsConfigSchema, ui: uiConfigSchema, + activity: activityConfigSchema, artifacts: artifactsConfigSchema, skills: skillsConfigSchema, subagents: subagentsConfigSchema.default({ diff --git a/src/config.test.ts b/src/config.test.ts index ddb2fc1c3..0690fb6d0 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -20,6 +20,7 @@ try { assert.deepEqual(defaults.allowedHosts, ["localhost", "127.0.0.1", "::1"]); assert.equal(defaults.toolMode, "codex"); assert.equal(defaults.uiEnabled, true); + assert.deepEqual(defaults.activity, { enabled: true, maxTurns: 200 }); assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); assert.deepEqual(defaults.subagents, { diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..bf1c878ef 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,7 @@ export interface ServerConfig { publicBaseUrl: string; toolMode: ToolMode; uiEnabled: boolean; + activity: { enabled: boolean; maxTurns: number }; stateDir: string; worktreeRoot: string; artifactsEnabled: boolean; @@ -67,6 +68,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { publicBaseUrl, toolMode: stored.tools.mode, uiEnabled: stored.ui.enabled, + activity: stored.activity, stateDir: normalizePath(stored.storage.stateDir), worktreeRoot: normalizePath(stored.workspaces.worktreeRoot), artifactsEnabled: stored.artifacts.enabled, diff --git a/src/server.ts b/src/server.ts index 4dcefbf26..a8771addb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -839,12 +839,14 @@ export function createServer( const reviewCheckpoints = createReviewCheckpointManager(); const processSessions = new ProcessSessionManager(); const toolActivities = new ToolActivityTracker(); - const workspaceActivity = new WorkspaceActivityStore(config.stateDir); - const workspaceActivityJournal = new WorkspaceActivityJournal(workspaceActivity, (error) => { - logEvent(config.logging, "warn", "workspace_activity_journal_error", { - error: error instanceof Error ? error.message : String(error), - }); - }); + const workspaceActivity = new WorkspaceActivityStore(config.stateDir, config.activity.maxTurns); + const workspaceActivityJournal = config.activity.enabled + ? new WorkspaceActivityJournal(workspaceActivity, (error) => { + logEvent(config.logging, "warn", "workspace_activity_journal_error", { + error: error instanceof Error ? error.message : String(error), + }); + }) + : undefined; const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), diff --git a/src/workspace-activity-journal.test.ts b/src/workspace-activity-journal.test.ts index 5428676fd..21abc30d3 100644 --- a/src/workspace-activity-journal.test.ts +++ b/src/workspace-activity-journal.test.ts @@ -30,6 +30,19 @@ test("tool calls belong to the open turn until a model show_changes closes it", assert.deepEqual(activity.listToolCalls(first!.id).map((entry) => entry.toolName), ["read", "show_changes"]); }); +test("closing a turn deletes closed turns beyond maxTurns with their calls", async (t) => { + const { journal, activity } = await journalFixture(t, ["ws_cap"], undefined, 2); + + for (let index = 0; index < 3; index += 1) { + await call(journal, "read", { workspace_id: "ws_cap", path: "README.md" }); + await call(journal, "show_changes", { workspace_id: "ws_cap" }, { + result: { structuredContent: { workspace_id: "ws_cap", review_ref: REVIEW_REF } }, + }); + } + + assert.deepEqual(activity.listTurns("ws_cap").map((turn) => turn.sequence), [3, 2]); +}); + test("card replays are recorded without closing the turn", async (t) => { const { journal, activity } = await journalFixture(t, ["ws_card"]); @@ -162,11 +175,12 @@ async function journalFixture( t: TestContext, workspaceIds: string[], onError?: (error: unknown) => void, + maxTurns?: number, ) { const stateDir = await mkdtemp(join(tmpdir(), "devspace-activity-journal-test-")); const workspaces = new SqliteWorkspaceStore(stateDir); for (const id of workspaceIds) workspaces.createSession({ id, root: "/tmp/project" }); - const activity = new WorkspaceActivityStore(stateDir); + const activity = new WorkspaceActivityStore(stateDir, maxTurns); const journal = new WorkspaceActivityJournal(activity, onError); t.after(async () => { try { diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts index f3a632d98..81dc0d982 100644 --- a/src/workspace-activity-store.ts +++ b/src/workspace-activity-store.ts @@ -77,7 +77,7 @@ export interface RecordWorkspaceToolCall { export class WorkspaceActivityStore { private readonly database: DatabaseHandle; - constructor(stateDir: string) { + constructor(stateDir: string, private readonly maxTurns = Number.POSITIVE_INFINITY) { this.database = openDatabase(stateDir); } @@ -127,6 +127,7 @@ export class WorkspaceActivityStore { .set({ status: "closed", reviewRef, closedAt: now.toISOString() }) .where(eq(workspaceTurns.id, turn.id)) .run(); + this.pruneClosedTurns(workspaceId); return { ...turn, status: "closed", reviewRef, closedAt: now.toISOString() }; } @@ -219,6 +220,20 @@ export class WorkspaceActivityStore { this.database.close(); } + /** Keeps the newest `maxTurns` closed turns; their tool calls cascade. */ + private pruneClosedTurns(workspaceId: string): void { + if (!Number.isFinite(this.maxTurns)) return; + this.database.sqlite.prepare(` + delete from workspace_turns + where workspace_session_id = ? and status = 'closed' and id not in ( + select id from workspace_turns + where workspace_session_id = ? and status = 'closed' + order by sequence desc + limit ? + ) + `).run(workspaceId, workspaceId, this.maxTurns); + } + private findOpenTurnRow(workspaceId: string): WorkspaceTurnRow | undefined { return this.database.db .select()