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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"dev": "node scripts/dev-server.mjs",
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
"start": "node dist/cli.js serve",
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts",
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/goal-store.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
6 changes: 6 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "full" }).toolMode, "f
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).toolMode, "codex");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "0" }).toolMode, "full");
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_MINIMAL_TOOLS: "1" }).toolMode, "minimal");
assert.equal(loadConfig(baseEnv).goalsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex" }).goalsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_TOOL_MODE: "codex", DEVSPACE_GOALS: "0" }).goalsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_GOALS: "1" }).goalsEnabled, true);
assert.equal(loadConfig(baseEnv).skillsEnabled, true);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "0" }).skillsEnabled, false);
assert.equal(loadConfig({ ...baseEnv, DEVSPACE_SKILLS: "1" }).skillsEnabled, true);
Expand Down Expand Up @@ -150,6 +154,7 @@ writeFileSync(
port: 8787,
allowedRoots: [process.cwd()],
publicBaseUrl: "https://devspace.example.com",
goalsEnabled: true,
}),
);
writeFileSync(
Expand All @@ -161,6 +166,7 @@ writeFileSync(

const fileConfig = loadConfig({ DEVSPACE_CONFIG_DIR: configDir });
assert.equal(fileConfig.port, 8787);
assert.equal(fileConfig.goalsEnabled, true);
assert.equal(fileConfig.oauth.ownerToken, "persisted-owner-token-long-enough");
assert.equal(fileConfig.publicBaseUrl, "https://devspace.example.com");
assert.deepEqual(fileConfig.allowedHosts, [
Expand Down
8 changes: 7 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ServerConfig {
widgets: WidgetMode;
stateDir: string;
worktreeRoot: string;
goalsEnabled: boolean;
skillsEnabled: boolean;
skillPaths: string[];
agentDir: string;
Expand Down Expand Up @@ -210,6 +211,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
const files = loadDevspaceFiles(env);
const host = env.HOST ?? files.config.host ?? "127.0.0.1";
const port = parsePort(env.PORT ?? files.config.port);
const toolMode = parseToolMode(env);
const publicBaseUrl = parsePublicBaseUrl(
env.DEVSPACE_PUBLIC_BASE_URL ?? files.config.publicBaseUrl ?? localPublicBaseUrl(host, port),
);
Expand All @@ -229,11 +231,15 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig {
allowedRoots: parseAllowedRoots(env.DEVSPACE_ALLOWED_ROOTS ?? files.config.allowedRoots),
allowedHosts: parseAllowedHosts(env.DEVSPACE_ALLOWED_HOSTS, derivedAllowedHosts),
publicBaseUrl,
toolMode: parseToolMode(env),
toolMode,
toolNaming: parseToolNaming(env.DEVSPACE_TOOL_NAMING),
widgets: parseWidgetMode(env.DEVSPACE_WIDGETS),
stateDir: resolve(expandHomePath(env.DEVSPACE_STATE_DIR ?? files.config.stateDir ?? defaultStateDir())),
worktreeRoot: resolve(expandHomePath(env.DEVSPACE_WORKTREE_ROOT ?? files.config.worktreeRoot ?? defaultWorktreeRoot())),
goalsEnabled:
env.DEVSPACE_GOALS === undefined
? (files.config.goalsEnabled ?? false)
: parseBoolean(env.DEVSPACE_GOALS),
skillsEnabled: env.DEVSPACE_SKILLS === undefined ? true : parseBoolean(env.DEVSPACE_SKILLS),
skillPaths: parsePathList(env.DEVSPACE_SKILL_PATHS),
agentDir: resolve(expandHomePath(env.DEVSPACE_AGENT_DIR ?? files.config.agentDir ?? defaultAgentDir())),
Expand Down
28 changes: 28 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ const migrations: Migration[] = [
name: "oauth-state",
up: migrateOAuthState,
},
{
version: 3,
name: "workspace-goals",
up: migrateWorkspaceGoals,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -138,6 +143,29 @@ function migrateOAuthState(sqlite: Database.Database): void {
`);
}

function migrateWorkspaceGoals(sqlite: Database.Database): void {
sqlite.exec(`
create table if not exists workspace_goals (
workspace_session_id text primary key,
goal_id text not null,
objective text not null,
status text not null check(status in ('active', 'blocked', 'complete')),
created_at text not null,
updated_at text not null,
completed_at text,
foreign key (workspace_session_id)
references workspace_sessions(id)
on delete cascade
);

create index if not exists workspace_goals_goal_id_idx
on workspace_goals(goal_id);

create index if not exists workspace_goals_status_idx
on workspace_goals(status, updated_at desc);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions",
Expand Down
20 changes: 20 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ export const loadedAgentFiles = sqliteTable(
],
);

export const workspaceGoals = sqliteTable(
"workspace_goals",
{
workspaceSessionId: text("workspace_session_id")
.primaryKey()
.references(() => workspaceSessions.id, { onDelete: "cascade" }),
goalId: text("goal_id").notNull(),
objective: text("objective").notNull(),
status: text("status", { enum: ["active", "blocked", "complete"] }).notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
completedAt: text("completed_at"),
},
(table) => [
index("workspace_goals_goal_id_idx").on(table.goalId),
index("workspace_goals_status_idx").on(table.status, table.updatedAt),
],
);

export const oauthClients = sqliteTable(
"oauth_clients",
{
Expand Down Expand Up @@ -77,3 +96,4 @@ export type WorkspaceSessionRow = typeof workspaceSessions.$inferSelect;
export type NewWorkspaceSessionRow = typeof workspaceSessions.$inferInsert;
export type LoadedAgentFileRow = typeof loadedAgentFiles.$inferSelect;
export type NewLoadedAgentFileRow = typeof loadedAgentFiles.$inferInsert;
export type WorkspaceGoalRow = typeof workspaceGoals.$inferSelect;
75 changes: 75 additions & 0 deletions src/goal-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SqliteGoalStore, validateObjective } from "./goal-store.js";
import { SqliteWorkspaceStore } from "./workspace-store.js";

const root = await mkdtemp(join(tmpdir(), "devspace-goal-store-test-"));

try {
testObjectiveValidation();
testGoalLifecycle(join(root, "lifecycle"));
testGoalPersistence(join(root, "persistence"));
} finally {
await rm(root, { recursive: true, force: true });
}

function testObjectiveValidation(): void {
assert.equal(validateObjective(" ship goals "), "ship goals");
assert.throws(() => validateObjective(" "), /Goal objective must not be empty/);
assert.throws(() => validateObjective("x".repeat(4001)), /must not exceed 4000/);
}

function testGoalLifecycle(stateDir: string): void {
const workspaceStore = new SqliteWorkspaceStore(stateDir);
const goalStore = new SqliteGoalStore(stateDir);
try {
const workspace = workspaceStore.createSession({ id: "ws_1", root: process.cwd() });
assert.equal(goalStore.getGoal(workspace.id), undefined);

const goal = goalStore.createGoal({ workspaceId: workspace.id, objective: " implement workspace goals " });
assert.equal(goal.workspaceId, workspace.id);
assert.equal(goal.objective, "implement workspace goals");
assert.equal(goal.status, "active");
assert.ok(goal.goalId);

assert.throws(
() => goalStore.createGoal({ workspaceId: workspace.id, objective: "replace too early" }),
/unfinished goal already exists/,
);

const blocked = goalStore.updateGoal({ workspaceId: workspace.id, status: "blocked" });
assert.equal(blocked?.status, "blocked");
assert.equal(blocked?.completedAt, undefined);

const complete = goalStore.updateGoal({ workspaceId: workspace.id, status: "complete" });
assert.equal(complete?.status, "complete");
assert.ok(complete?.completedAt);

const replacement = goalStore.createGoal({ workspaceId: workspace.id, objective: "next goal" });
assert.equal(replacement.status, "active");
assert.equal(replacement.objective, "next goal");
assert.notEqual(replacement.goalId, goal.goalId);
} finally {
goalStore.close();
workspaceStore.close();
}
}

function testGoalPersistence(stateDir: string): void {
const workspaceStore = new SqliteWorkspaceStore(stateDir);
const firstGoalStore = new SqliteGoalStore(stateDir);
const workspace = workspaceStore.createSession({ id: "ws_2", root: process.cwd() });
const created = firstGoalStore.createGoal({ workspaceId: workspace.id, objective: "persist me" });
firstGoalStore.close();
workspaceStore.close();

const secondGoalStore = new SqliteGoalStore(stateDir);
try {
assert.deepEqual(secondGoalStore.getGoal(workspace.id), created);
assert.equal(secondGoalStore.updateGoal({ workspaceId: "missing", status: "complete" }), undefined);
} finally {
secondGoalStore.close();
}
}
137 changes: 137 additions & 0 deletions src/goal-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { openDatabase, type DatabaseHandle } from "./db/client.js";
import { workspaceGoals, type WorkspaceGoalRow } from "./db/schema.js";

export type GoalStatus = "active" | "blocked" | "complete";
export type ModelSettableGoalStatus = "blocked" | "complete";

export interface WorkspaceGoal {
workspaceId: string;
goalId: string;
objective: string;
status: GoalStatus;
createdAt: string;
updatedAt: string;
completedAt?: string;
}

export interface GoalStore {
getGoal(workspaceId: string): WorkspaceGoal | undefined;
createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal;
updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined;
close?(): void;
}

export class SqliteGoalStore implements GoalStore {
private readonly database: DatabaseHandle;

constructor(stateDir: string) {
this.database = openDatabase(stateDir);
}

getGoal(workspaceId: string): WorkspaceGoal | undefined {
const row = this.database.db
.select()
.from(workspaceGoals)
.where(eq(workspaceGoals.workspaceSessionId, workspaceId))
.get();

return row ? rowToWorkspaceGoal(row) : undefined;
}

createGoal(input: { workspaceId: string; objective: string }): WorkspaceGoal {
const objective = validateObjective(input.objective);
const existing = this.getGoal(input.workspaceId);
if (existing && existing.status !== "complete") {
throw new Error("An unfinished goal already exists for this workspace. Use update_goal to change its status.");
}

const now = new Date().toISOString();
const goal: WorkspaceGoal = {
workspaceId: input.workspaceId,
goalId: randomUUID(),
objective,
status: "active",
createdAt: now,
updatedAt: now,
};

this.database.db
.insert(workspaceGoals)
.values({
workspaceSessionId: goal.workspaceId,
goalId: goal.goalId,
objective: goal.objective,
status: goal.status,
createdAt: goal.createdAt,
updatedAt: goal.updatedAt,
completedAt: null,
})
.onConflictDoUpdate({
target: workspaceGoals.workspaceSessionId,
set: {
goalId: goal.goalId,
objective: goal.objective,
status: goal.status,
createdAt: goal.createdAt,
updatedAt: goal.updatedAt,
completedAt: null,
},
})
.run();

return goal;
}

updateGoal(input: { workspaceId: string; status: ModelSettableGoalStatus }): WorkspaceGoal | undefined {
const existing = this.getGoal(input.workspaceId);
if (!existing) return undefined;
if (existing.status === "complete") return existing;

const now = new Date().toISOString();
this.database.db
.update(workspaceGoals)
.set({
status: input.status,
updatedAt: now,
completedAt: input.status === "complete" ? now : null,
})
.where(eq(workspaceGoals.workspaceSessionId, input.workspaceId))
.run();

return this.getGoal(input.workspaceId);
}

close(): void {
this.database.close();
}
}

export function createGoalStore(stateDir: string): GoalStore {
return new SqliteGoalStore(stateDir);
}

export function validateObjective(value: string): string {
const objective = value.trim();
if (!objective) {
throw new Error("Goal objective must not be empty.");
}
if (objective.length > 4000) {
throw new Error("Goal objective must not exceed 4000 characters.");
}
return objective;
}

function rowToWorkspaceGoal(row: WorkspaceGoalRow): WorkspaceGoal {
const goal: WorkspaceGoal = {
workspaceId: row.workspaceSessionId,
goalId: row.goalId,
objective: row.objective,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
if (row.completedAt) goal.completedAt = row.completedAt;
return goal;
}
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
assert.deepEqual(migrations, [
{ version: 1, name: "workspace-state" },
{ version: 2, name: "oauth-state" },
{ version: 3, name: "workspace-goals" },
]);
} finally {
database.close();
Expand Down
Loading
Loading