Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
{
Expand Down Expand Up @@ -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;
70 changes: 69 additions & 1 deletion src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<Client["callTool"]>[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, {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -645,6 +702,7 @@ async function fixture(
subagents?: SubagentsConfig;
toolMode?: ToolMode;
uiEnabled?: boolean;
captureActivity?: boolean;
} = {},
): Promise<ServerFixture> {
const root = await mkdtemp(join(tmpdir(), "devspace-server-test-"));
Expand Down Expand Up @@ -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,
Expand All @@ -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" });
Expand All @@ -736,6 +802,8 @@ async function fixture(
closed = true;
await client.close();
await server.close();
activity?.close();
activityJournal?.close();
store.close();
};

Expand All @@ -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<void> {
Expand Down
40 changes: 33 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -331,6 +333,7 @@ export function createMcpServer(
resolveLocalAgentProviders,
incomingArtifactAdapters,
trackToolActivity,
workspaceActivityJournal,
);
return server;
}
Expand All @@ -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);

Expand Down Expand Up @@ -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<string, unknown>,
operation,
})
: operation;
return options.trackToolActivity
? options.trackToolActivity(observedOperation)
: observedOperation();
},
);
}) as McpRegistrationTarget["registerTool"],
registerResource: server.registerResource.bind(server),
Expand Down Expand Up @@ -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),
Expand All @@ -842,6 +866,7 @@ export function createServer(
resolveLocalAgentProviders,
incomingArtifactAdapters,
toolActivities.track,
workspaceActivityJournal,
);
});
const logMcpHandlerError = (error: Error) => logEvent(
Expand Down Expand Up @@ -978,6 +1003,7 @@ export function createServer(
});
}
await toolActivities.waitForIdle();
workspaceActivityJournal.close();
processSessions.shutdown();
oauthProvider.close();
workspaceStore.close?.();
Expand Down
81 changes: 81 additions & 0 deletions src/workspace-activity-journal.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading
Loading