Skip to content
16 changes: 16 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions schema/v1/devspace.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]),
Expand Down Expand Up @@ -76,6 +83,7 @@ export const devspaceConfigSchema = z.object({
storage: storageConfigSchema,
tools: toolsConfigSchema,
ui: uiConfigSchema,
activity: activityConfigSchema,
artifacts: artifactsConfigSchema,
skills: skillsConfigSchema,
subagents: subagentsConfigSchema.default({
Expand Down
1 change: 1 addition & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ServerConfig {
publicBaseUrl: string;
toolMode: ToolMode;
uiEnabled: boolean;
activity: { enabled: boolean; maxTurns: number };
stateDir: string;
worktreeRoot: string;
artifactsEnabled: boolean;
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 51 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-activity",
up: migrateWorkspaceActivity,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -289,6 +294,52 @@ function migrateLocalAgentTurns(sqlite: Database.Database): void {
`);
}

function migrateWorkspaceActivity(sqlite: Database.Database): void {
sqlite.exec(`
create table if not exists workspace_turns (
id integer primary key autoincrement,
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 not null,
duration_ms integer not null,
foreign key (turn_id)
references workspace_turns(id)
on delete cascade
);

create index if not exists workspace_turn_tool_calls_turn_idx
on workspace_turn_tool_calls(turn_id, started_at);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions",
Expand Down
46 changes: 45 additions & 1 deletion src/db/schema.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -56,6 +56,48 @@ export const workspaceConversationBindings = sqliteTable(
],
);

export const workspaceTurns = sqliteTable(
"workspace_turns",
{
id: integer("id").primaryKey({ autoIncrement: true }),
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").notNull(),
durationMs: integer("duration_ms").notNull(),
},
(table) => [
index("workspace_turn_tool_calls_turn_idx").on(table.turnId, table.startedAt),
],
);

export const oauthClients = sqliteTable(
"oauth_clients",
{
Expand Down Expand Up @@ -123,5 +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 WorkspaceTurnRow = typeof workspaceTurns.$inferSelect;
export type WorkspaceTurnToolCallRow = typeof workspaceTurnToolCalls.$inferSelect;
export type LocalAgentSessionRow = typeof localAgentSessions.$inferSelect;
export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert;
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ 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();
Expand Down
20 changes: 20 additions & 0 deletions src/review-checkpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 14 additions & 1 deletion src/review-checkpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface WorkspaceReviewState {
gitRoot?: string;
openRef: string;
baselineRef: string;
checkpointRefPrefix: string;
openRefAvailable: boolean;
baselineRefAvailable: boolean;
diagnostic?: string;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -274,11 +285,12 @@ async function commitForRef(gitRoot: string, ref: string): Promise<string | unde

function reviewRefs(
workspaceId: string,
): Pick<WorkspaceReviewState, "openRef" | "baselineRef"> {
): Pick<WorkspaceReviewState, "openRef" | "baselineRef" | "checkpointRefPrefix"> {
const segment = safeWorkspaceRefSegment(workspaceId);
return {
openRef: `${REVIEW_REF_PREFIX}/${segment}/open`,
baselineRef: `${REVIEW_REF_PREFIX}/${segment}/baseline`,
checkpointRefPrefix: `${REVIEW_REF_PREFIX}/${segment}/checkpoints`,
};
}

Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading