From c1ee1059e4645586611cbc94f84bfaa715edb05f 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/4] 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 8bb1f7e4..cd98035d 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 77b23436..2ef93087 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 00000000..eb3a879e --- /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 00000000..efcc7736 --- /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 b9132c4ce941f4176648528514eb4dc1346d5bb1 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/4] 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 d92ae592..f6d1dddf 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); @@ -775,18 +779,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), @@ -823,6 +842,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), @@ -842,6 +866,7 @@ export function createServer( resolveLocalAgentProviders, incomingArtifactAdapters, toolActivities.track, + workspaceActivityJournal, ); }); const logMcpHandlerError = (error: Error) => logEvent( @@ -978,6 +1003,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 00000000..e84fd93e --- /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 00000000..41e18f25 --- /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 0a49f3547f87b6a026256292004de66a3d189127 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/4] 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 7f9fe15c..cc1b736a 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); @@ -235,6 +238,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, { @@ -563,6 +619,7 @@ test("server shutdown waits for an active MCP tool call", async (t) => { interface ServerFixture { client: Client; project: string; + activity?: WorkspaceActivityStore; } function schemaPropertyPaths( @@ -645,6 +702,7 @@ async function fixture( subagents?: SubagentsConfig; toolMode?: ToolMode; uiEnabled?: boolean; + captureActivity?: boolean; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -714,6 +772,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, @@ -722,6 +786,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" }); @@ -736,6 +802,8 @@ async function fixture( closed = true; await client.close(); await server.close(); + activity?.close(); + activityJournal?.close(); store.close(); }; @@ -744,7 +812,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 00000000..8506d078 --- /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 00000000..7b1e566d --- /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 68babb8bacfd6aa4593b8da7ae382e3f612f9c64 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/4] 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 eb3a879e..8ff02434 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 efcc7736..799d13af 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 8506d078..80abec64 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 7b1e566d..31dbc6f2 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"; }