Skip to content
Draft
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 @@ -28,7 +28,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/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.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/cli.test.ts && tsx src/workflow-types.test.ts",
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.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/cli.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"keywords": [],
Expand Down
89 changes: 89 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const migrations: Migration[] = [
name: "local-agent-effort-rename",
up: migrateLocalAgentEffortRename,
},
{
version: 5,
name: "workflow-journal",
up: migrateWorkflowJournal,
},
];

export function migrateDatabase(sqlite: Database.Database): void {
Expand Down Expand Up @@ -193,6 +198,90 @@ function migrateLocalAgentEffortRename(sqlite: Database.Database): void {
sqlite.exec("alter table local_agent_sessions rename column thinking to effort");
}

function migrateWorkflowJournal(sqlite: Database.Database): void {
sqlite.exec(`
create table if not exists workflow_runs (
id text primary key,
name text not null,
source text not null,
script_path text not null,
script_hash text not null,
workspace_root text not null,
workspace_id text,
args_json text not null default 'null',
status text not null,
error text,
error_kind text,
result_json text,
pid integer,
heartbeat_at text,
cancel_requested text not null default 'false',
resumed_from_run_id text,
base_sha text,
created_at text not null,
started_at text,
completed_at text,
updated_at text not null
);

create index if not exists workflow_runs_status_updated_idx
on workflow_runs(status, updated_at desc);

create index if not exists workflow_runs_workspace_updated_idx
on workflow_runs(workspace_root, updated_at desc);

create index if not exists workflow_runs_heartbeat_idx
on workflow_runs(status, heartbeat_at);

create index if not exists workflow_runs_resumed_from_idx
on workflow_runs(resumed_from_run_id);

create table if not exists workflow_events (
run_id text not null,
seq integer not null,
type text not null,
phase text,
label text,
data_json text not null default '{}',
created_at text not null,
primary key (run_id, seq),
foreign key (run_id) references workflow_runs(id) on delete cascade
);

create index if not exists workflow_events_run_seq_idx
on workflow_events(run_id, seq);

create table if not exists workflow_agent_calls (
run_id text not null,
call_index integer not null,
cache_key text not null,
provider text not null,
model text,
effort text,
label text,
phase text,
status text not null,
from_cache text not null default 'false',
provider_session_id text,
response_text text,
structured_json text,
error text,
isolation text not null default 'shared',
worktree_path text,
dirty text,
created_at text not null,
started_at text,
completed_at text,
updated_at text not null,
primary key (run_id, call_index),
foreign key (run_id) references workflow_runs(id) on delete cascade
);

create index if not exists workflow_agent_calls_cache_key_idx
on workflow_agent_calls(run_id, cache_key);
`);
}

function addColumnIfMissing(
sqlite: Database.Database,
table: "workspace_sessions" | "local_agent_sessions",
Expand Down
88 changes: 88 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,97 @@ export const localAgentSessions = sqliteTable(
],
);

export const workflowRuns = sqliteTable(
"workflow_runs",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
source: text("source").notNull(),
scriptPath: text("script_path").notNull(),
scriptHash: text("script_hash").notNull(),
workspaceRoot: text("workspace_root").notNull(),
workspaceId: text("workspace_id"),
argsJson: text("args_json").notNull().default("null"),
status: text("status").notNull(),
error: text("error"),
errorKind: text("error_kind"),
resultJson: text("result_json"),
pid: integer("pid"),
heartbeatAt: text("heartbeat_at"),
cancelRequested: text("cancel_requested").notNull().default("false"),
resumedFromRunId: text("resumed_from_run_id"),
baseSha: text("base_sha"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
completedAt: text("completed_at"),
updatedAt: text("updated_at").notNull(),
},
(table) => [
index("workflow_runs_status_updated_idx").on(table.status, table.updatedAt),
index("workflow_runs_workspace_updated_idx").on(table.workspaceRoot, table.updatedAt),
index("workflow_runs_heartbeat_idx").on(table.status, table.heartbeatAt),
index("workflow_runs_resumed_from_idx").on(table.resumedFromRunId),
],
);

export const workflowEvents = sqliteTable(
"workflow_events",
{
runId: text("run_id")
.notNull()
.references(() => workflowRuns.id, { onDelete: "cascade" }),
seq: integer("seq").notNull(),
type: text("type").notNull(),
phase: text("phase"),
label: text("label"),
dataJson: text("data_json").notNull().default("{}"),
createdAt: text("created_at").notNull(),
},
(table) => [
primaryKey({ columns: [table.runId, table.seq] }),
index("workflow_events_run_seq_idx").on(table.runId, table.seq),
],
);

export const workflowAgentCalls = sqliteTable(
"workflow_agent_calls",
{
runId: text("run_id")
.notNull()
.references(() => workflowRuns.id, { onDelete: "cascade" }),
callIndex: integer("call_index").notNull(),
cacheKey: text("cache_key").notNull(),
provider: text("provider").notNull(),
model: text("model"),
effort: text("effort"),
label: text("label"),
phase: text("phase"),
status: text("status").notNull(),
fromCache: text("from_cache").notNull().default("false"),
providerSessionId: text("provider_session_id"),
responseText: text("response_text"),
structuredJson: text("structured_json"),
error: text("error"),
isolation: text("isolation").notNull().default("shared"),
worktreePath: text("worktree_path"),
dirty: text("dirty"),
createdAt: text("created_at").notNull(),
startedAt: text("started_at"),
completedAt: text("completed_at"),
updatedAt: text("updated_at").notNull(),
},
(table) => [
primaryKey({ columns: [table.runId, table.callIndex] }),
index("workflow_agent_calls_cache_key_idx").on(table.runId, table.cacheKey),
],
);

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 LocalAgentSessionRow = typeof localAgentSessions.$inferSelect;
export type NewLocalAgentSessionRow = typeof localAgentSessions.$inferInsert;
export type WorkflowRunRow = typeof workflowRuns.$inferSelect;
export type WorkflowEventRow = typeof workflowEvents.$inferSelect;
export type WorkflowAgentCallRow = typeof workflowAgentCalls.$inferSelect;
1 change: 1 addition & 0 deletions src/oauth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async function testDatabaseConfiguration(stateDir: string): Promise<void> {
{ version: 2, name: "oauth-state" },
{ version: 3, name: "local-agent-sessions" },
{ version: 4, name: "local-agent-effort-rename" },
{ version: 5, name: "workflow-journal" },
]);
} finally {
database.close();
Expand Down
83 changes: 83 additions & 0 deletions src/workflow-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { parseWorkflowScript } from "./workflow-script.js";
import { createStubBudget, type WorkflowMeta } from "./workflow-types.js";
import { runWorkflowSandbox, WorkflowDeterminismError } from "./workflow-sandbox.js";

function api(meta: WorkflowMeta, logs?: string[]) {
return {
agent: async () => "",
parallel: async () => [],
pipeline: async () => [],
phase: () => {},
log: (msg: unknown) => {
logs?.push(String(msg));
},
args: undefined as unknown,
budget: createStubBudget(),
workflow: async () => null,
meta,
};
}

{
const logs: string[] = [];
const parsed = parseWorkflowScript(`
export const meta = { name: 'console-test', description: 'd' }
console.log('a', { b: 1 })
console.warn('w')
return 'ok'
`);
const result = await runWorkflowSandbox({ parsed, api: api(parsed.meta, logs) });
assert.equal(result, "ok");
assert.equal(logs[0], 'a {"b":1}');
assert.equal(logs[1], "w");
}

{
const parsed = parseWorkflowScript(`
export const meta = { name: 'math-abs-ok', description: 'd' }
return Math.abs(-3)
`);
const abs = await runWorkflowSandbox({ parsed, api: api(parsed.meta) });
assert.equal(abs, 3);
}

{
await assert.rejects(
() =>
runWorkflowSandbox({
parsed: parseWorkflowScript(`
export const meta = { name: 'fetch-ban', description: 'd' }
return fetch('https://example.com')
`),
api: api({ name: "fetch-ban", description: "d" }),
}),
/fetch is not defined|ReferenceError/,
);
}

{
const parsed = parseWorkflowScript(`
export const meta = { name: 'budget', description: 'd' }
return { total: budget.total, spent: budget.spent(), remaining: budget.remaining() }
`);
const budgetResult = await runWorkflowSandbox({ parsed, api: api(parsed.meta) });
assert.deepEqual(budgetResult, { total: null, spent: 0, remaining: Infinity });
}

{
await assert.rejects(
() =>
runWorkflowSandbox({
parsed: parseWorkflowScript(`
export const meta = { name: 'rnd', description: 'd' }
return Math.random()
`),
api: api({ name: "rnd", description: "d" }),
}),
(error: unknown) =>
error instanceof WorkflowDeterminismError && /Math\.random/.test(error.message),
);
}

console.log("workflow-sandbox.test.ts: ok");
Loading
Loading