// @ts-nocheck import { randomUUID } from "node:crypto"; import { basename } from "node:path"; import { asc, eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../context.js"; import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance, selfTask } from "../handles.js"; import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js"; import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; const STATUS_SYNC_INTERVAL_MS = 1_000; function emptyGitState() { return { fileChanges: [], diffs: {}, fileTree: [], updatedAt: null as number | null, }; } async function ensureWorkbenchSessionTable(c: any): Promise { await c.db.execute(` CREATE TABLE IF NOT EXISTS task_workbench_sessions ( session_id text PRIMARY KEY NOT NULL, sandbox_session_id text, session_name text NOT NULL, model text NOT NULL, status text DEFAULT 'ready' NOT NULL, error_message text, transcript_json text DEFAULT '[]' NOT NULL, transcript_updated_at integer, unread integer DEFAULT 0 NOT NULL, draft_text text DEFAULT '' NOT NULL, draft_attachments_json text DEFAULT '[]' NOT NULL, draft_updated_at integer, created integer DEFAULT 1 NOT NULL, closed integer DEFAULT 0 NOT NULL, thinking_since_ms integer, created_at integer NOT NULL, updated_at integer NOT NULL ) `); await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN sandbox_session_id text`).catch(() => {}); await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN status text DEFAULT 'ready' NOT NULL`).catch(() => {}); await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN error_message text`).catch(() => {}); await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_json text DEFAULT '[]' NOT NULL`).catch(() => {}); await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_updated_at integer`).catch(() => {}); } async function ensureTaskRuntimeCacheColumns(c: any): Promise { await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {}); await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {}); await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {}); await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {}); } function defaultModelForAgent(agentType: string | null | undefined) { return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4"; } function agentKindForModel(model: string) { if (model === "gpt-4o" || model === "o3") { return "Codex"; } return "Claude"; } export function agentTypeForModel(model: string) { if (model === "gpt-4o" || model === "o3") { return "codex"; } return "claude"; } function repoLabelFromRemote(remoteUrl: string): string { const trimmed = remoteUrl.trim(); try { const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); if (parts.length >= 2) { return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`; } } catch { // ignore } return basename(trimmed.replace(/\.git$/, "")); } function parseDraftAttachments(value: string | null | undefined): Array { if (!value) { return []; } try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function parseTranscript(value: string | null | undefined): Array { if (!value) { return []; } try { const parsed = JSON.parse(value) as unknown; return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function parseGitState(value: string | null | undefined): { fileChanges: Array; diffs: Record; fileTree: Array } { if (!value) { return emptyGitState(); } try { const parsed = JSON.parse(value) as { fileChanges?: unknown; diffs?: unknown; fileTree?: unknown; }; return { fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [], diffs: parsed.diffs && typeof parsed.diffs === "object" ? (parsed.diffs as Record) : {}, fileTree: Array.isArray(parsed.fileTree) ? parsed.fileTree : [], }; } catch { return emptyGitState(); } } export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { if (status === "running") { return false; } // Only mark unread when we observe the transition out of an active thinking state. // Repeated idle polls for an already-finished session must not flip unread back on. return Boolean(meta.thinkingSinceMs); } async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise> { await ensureWorkbenchSessionTable(c); const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all(); const mapped = rows.map((row: any) => ({ ...row, id: row.sessionId, sessionId: row.sandboxSessionId ?? null, tabId: row.sessionId, sandboxSessionId: row.sandboxSessionId ?? null, status: row.status ?? "ready", errorMessage: row.errorMessage ?? null, transcript: parseTranscript(row.transcriptJson), transcriptUpdatedAt: row.transcriptUpdatedAt ?? null, draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), draftUpdatedAtMs: row.draftUpdatedAt ?? null, unread: row.unread === 1, created: row.created === 1, closed: row.closed === 1, })); if (options?.includeClosed === true) { return mapped; } return mapped.filter((row: any) => row.closed !== true); } async function nextSessionName(c: any): Promise { const rows = await listSessionMetaRows(c, { includeClosed: true }); return `Session ${rows.length + 1}`; } async function readSessionMeta(c: any, sessionId: string): Promise { await ensureWorkbenchSessionTable(c); const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get(); if (!row) { return null; } return { ...row, id: row.sessionId, sessionId: row.sandboxSessionId ?? null, tabId: row.sessionId, sandboxSessionId: row.sandboxSessionId ?? null, status: row.status ?? "ready", errorMessage: row.errorMessage ?? null, transcript: parseTranscript(row.transcriptJson), transcriptUpdatedAt: row.transcriptUpdatedAt ?? null, draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), draftUpdatedAtMs: row.draftUpdatedAt ?? null, unread: row.unread === 1, created: row.created === 1, closed: row.closed === 1, }; } async function ensureSessionMeta( c: any, params: { tabId: string; sandboxSessionId?: string | null; model?: string; sessionName?: string; unread?: boolean; created?: boolean; status?: "pending_provision" | "pending_session_create" | "ready" | "error"; errorMessage?: string | null; }, ): Promise { await ensureWorkbenchSessionTable(c); const existing = await readSessionMeta(c, params.tabId); if (existing) { return existing; } const now = Date.now(); const sessionName = params.sessionName ?? (await nextSessionName(c)); const model = params.model ?? defaultModelForAgent(c.state.agentType); const unread = params.unread ?? false; await c.db .insert(taskWorkbenchSessions) .values({ sessionId: params.tabId, sandboxSessionId: params.sandboxSessionId ?? null, sessionName, model, status: params.status ?? "ready", errorMessage: params.errorMessage ?? null, transcriptJson: "[]", transcriptUpdatedAt: null, unread: unread ? 1 : 0, draftText: "", draftAttachmentsJson: "[]", draftUpdatedAt: null, created: params.created === false ? 0 : 1, closed: 0, thinkingSinceMs: null, createdAt: now, updatedAt: now, }) .run(); return await readSessionMeta(c, params.tabId); } async function updateSessionMeta(c: any, tabId: string, values: Record): Promise { await ensureSessionMeta(c, { tabId }); await c.db .update(taskWorkbenchSessions) .set({ ...values, updatedAt: Date.now(), }) .where(eq(taskWorkbenchSessions.sessionId, tabId)) .run(); return await readSessionMeta(c, tabId); } async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise { await ensureWorkbenchSessionTable(c); const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get(); if (!row) { return null; } return await readSessionMeta(c, row.sessionId); } async function requireReadySessionMeta(c: any, tabId: string): Promise { const meta = await readSessionMeta(c, tabId); if (!meta) { throw new Error(`Unknown workbench tab: ${tabId}`); } if (meta.status !== "ready" || !meta.sandboxSessionId) { throw new Error(meta.errorMessage ?? "This workbench tab is still preparing"); } return meta; } function shellFragment(parts: string[]): string { return parts.join(" && "); } async function executeInSandbox( c: any, params: { sandboxId: string; cwd: string; command: string; label: string; }, ): Promise<{ exitCode: number; result: string }> { const { providers } = getActorRuntimeContext(); const provider = providers.get(c.state.providerId); return await provider.executeCommand({ workspaceId: c.state.workspaceId, sandboxId: params.sandboxId, command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`, label: params.label, }); } function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> { return output .split("\n") .map((line) => line.trimEnd()) .filter(Boolean) .map((line) => { const status = line.slice(0, 2).trim(); const rawPath = line.slice(3).trim(); const path = rawPath.includes(" -> ") ? (rawPath.split(" -> ").pop() ?? rawPath) : rawPath; const type = status.includes("D") ? "D" : status.includes("A") || status === "??" ? "A" : "M"; return { path, type }; }); } function parseNumstat(output: string): Map { const map = new Map(); for (const line of output.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const [addedRaw, removedRaw, ...pathParts] = trimmed.split("\t"); const path = pathParts.join("\t").trim(); if (!path) continue; map.set(path, { added: Number.parseInt(addedRaw ?? "0", 10) || 0, removed: Number.parseInt(removedRaw ?? "0", 10) || 0, }); } return map; } function buildFileTree(paths: string[]): Array { const root = { children: new Map(), }; for (const path of paths) { const parts = path.split("/").filter(Boolean); let current = root; let currentPath = ""; for (let index = 0; index < parts.length; index += 1) { const part = parts[index]!; currentPath = currentPath ? `${currentPath}/${part}` : part; const isDir = index < parts.length - 1; let node = current.children.get(part); if (!node) { node = { name: part, path: currentPath, isDir, children: isDir ? new Map() : undefined, }; current.children.set(part, node); } else if (isDir && !(node.children instanceof Map)) { node.children = new Map(); } current = node; } } function sortNodes(nodes: Iterable): Array { return [...nodes] .map((node) => node.isDir ? { name: node.name, path: node.path, isDir: true, children: sortNodes(node.children?.values?.() ?? []), } : { name: node.name, path: node.path, isDir: false, }, ) .sort((left, right) => { if (left.isDir !== right.isDir) { return left.isDir ? -1 : 1; } return left.path.localeCompare(right.path); }); } return sortNodes(root.children.values()); } async function collectWorkbenchGitState(c: any, record: any) { const activeSandboxId = record.activeSandboxId; const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; if (!activeSandboxId || !cwd) { return { fileChanges: [], diffs: {}, fileTree: [], }; } const statusResult = await executeInSandbox(c, { sandboxId: activeSandboxId, cwd, command: "git status --porcelain=v1 -uall", label: "git status", }); if (statusResult.exitCode !== 0) { return { fileChanges: [], diffs: {}, fileTree: [], }; } const statusRows = parseGitStatus(statusResult.result); const numstatResult = await executeInSandbox(c, { sandboxId: activeSandboxId, cwd, command: "git diff --numstat", label: "git diff numstat", }); const numstat = parseNumstat(numstatResult.result); const filesResult = await executeInSandbox(c, { sandboxId: activeSandboxId, cwd, command: "git ls-files --cached --others --exclude-standard", label: "git ls-files", }); const allPaths = filesResult.result .split("\n") .map((line) => line.trim()) .filter(Boolean); const diffs: Record = {}; for (const row of statusRows) { const diffResult = await executeInSandbox(c, { sandboxId: activeSandboxId, cwd, command: `git diff -- ${JSON.stringify(row.path)}`, label: `git diff ${row.path}`, }); diffs[row.path] = diffResult.exitCode === 0 ? diffResult.result : ""; } return { fileChanges: statusRows.map((row) => { const counts = numstat.get(row.path) ?? { added: 0, removed: 0 }; return { path: row.path, added: counts.added, removed: counts.removed, type: row.type, }; }), diffs, fileTree: buildFileTree(allPaths), }; } async function readCachedGitState(c: any): Promise<{ fileChanges: Array; diffs: Record; fileTree: Array; updatedAt: number | null }> { await ensureTaskRuntimeCacheColumns(c); const row = await c.db .select({ gitStateJson: taskRuntime.gitStateJson, gitStateUpdatedAt: taskRuntime.gitStateUpdatedAt, }) .from(taskRuntime) .where(eq(taskRuntime.id, 1)) .get(); const parsed = parseGitState(row?.gitStateJson); return { ...parsed, updatedAt: row?.gitStateUpdatedAt ?? null, }; } async function writeCachedGitState(c: any, gitState: { fileChanges: Array; diffs: Record; fileTree: Array }): Promise { await ensureTaskRuntimeCacheColumns(c); const now = Date.now(); await c.db .update(taskRuntime) .set({ gitStateJson: JSON.stringify(gitState), gitStateUpdatedAt: now, updatedAt: now, }) .where(eq(taskRuntime.id, 1)) .run(); } async function readSessionTranscript(c: any, record: any, sessionId: string) { const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null; if (!sandboxId) { return []; } const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId); const page = await sandbox.listSessionEvents({ sessionId, limit: 100, }); return page.items.map((event: any) => ({ id: event.id, eventIndex: event.eventIndex, sessionId: event.sessionId, createdAt: event.createdAt, connectionId: event.connectionId, sender: event.sender, payload: event.payload, })); } async function writeSessionTranscript(c: any, tabId: string, transcript: Array): Promise { await updateSessionMeta(c, tabId, { transcriptJson: JSON.stringify(transcript), transcriptUpdatedAt: Date.now(), }); } async function enqueueWorkbenchRefresh( c: any, command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript", body: Record, ): Promise { const self = selfTask(c); await self.send(command, body, { wait: false }); } async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array): Promise { const gitState = await readCachedGitState(c); if (record.activeSandboxId && !gitState.updatedAt) { await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); } for (const session of sessions) { if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) { continue; } await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId: session.sandboxSessionId, }); } } function activeSessionStatus(record: any, sessionId: string) { if (record.activeSessionId !== sessionId) { return "idle"; } if (record.status === "running") { return "running"; } if (record.status === "error") { return "error"; } return "idle"; } async function readPullRequestSummary(c: any, branchName: string | null) { if (!branchName) { return null; } try { const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote); return await project.getPullRequestForBranch({ branchName }); } catch { return null; } } export async function ensureWorkbenchSeeded(c: any): Promise { await ensureTaskRuntimeCacheColumns(c); const record = await getCurrentRecord({ db: c.db, state: c.state }); if (record.activeSessionId) { await ensureSessionMeta(c, { tabId: record.activeSessionId, sandboxSessionId: record.activeSessionId, model: defaultModelForAgent(record.agentType), sessionName: "Session 1", status: "ready", }); } return record; } function buildSessionSummary(record: any, meta: any): any { const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null); const sessionStatus = meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle"; let thinkingSinceMs = meta.thinkingSinceMs ?? null; let unread = Boolean(meta.unread); if (thinkingSinceMs && sessionStatus !== "running") { thinkingSinceMs = null; unread = true; } return { id: meta.id, sessionId: derivedSandboxSessionId, sessionName: meta.sessionName, agent: agentKindForModel(meta.model), model: meta.model, status: sessionStatus, thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null, unread, created: Boolean(meta.created || derivedSandboxSessionId), }; } function buildSessionDetailFromMeta(record: any, meta: any): any { const summary = buildSessionSummary(record, meta); return { sessionId: meta.tabId, tabId: meta.tabId, sandboxSessionId: summary.sessionId, sessionName: summary.sessionName, agent: summary.agent, model: summary.model, status: summary.status, thinkingSinceMs: summary.thinkingSinceMs, unread: summary.unread, created: summary.created, draft: { text: meta.draftText ?? "", attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [], updatedAtMs: meta.draftUpdatedAtMs ?? null, }, transcript: meta.transcript ?? [], }; } /** * Builds a WorkbenchTaskSummary from local task actor state. Task actors push * this to the parent workspace actor so workspace sidebar reads stay local. */ export async function buildTaskSummary(c: any): Promise { const record = await ensureWorkbenchSeeded(c); const sessions = await listSessionMetaRows(c); await maybeScheduleWorkbenchRefreshes(c, record, sessions); return { id: c.state.taskId, repoId: c.state.repoId, title: record.title ?? "New Task", status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new", repoName: repoLabelFromRemote(c.state.repoRemote), updatedAtMs: record.updatedAt, branch: record.branchName, pullRequest: await readPullRequestSummary(c, record.branchName), sessionsSummary: sessions.map((meta) => buildSessionSummary(record, meta)), }; } /** * Builds a WorkbenchTaskDetail from local task actor state for direct task * subscribers. This is a full replacement payload, not a patch. */ export async function buildTaskDetail(c: any): Promise { const record = await ensureWorkbenchSeeded(c); const gitState = await readCachedGitState(c); const sessions = await listSessionMetaRows(c); await maybeScheduleWorkbenchRefreshes(c, record, sessions); const summary = await buildTaskSummary(c); return { ...summary, task: record.task, agentType: record.agentType === "claude" || record.agentType === "codex" ? record.agentType : null, runtimeStatus: record.status, statusMessage: record.statusMessage ?? null, activeSessionId: record.activeSessionId ?? null, diffStat: record.diffStat ?? null, prUrl: record.prUrl ?? null, reviewStatus: record.reviewStatus ?? null, fileChanges: gitState.fileChanges, diffs: gitState.diffs, fileTree: gitState.fileTree, minutesUsed: 0, sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({ providerId: sandbox.providerId, sandboxId: sandbox.sandboxId, cwd: sandbox.cwd ?? null, })), activeSandboxId: record.activeSandboxId ?? null, }; } /** * Builds a WorkbenchSessionDetail for a specific session tab. */ export async function buildSessionDetail(c: any, tabId: string): Promise { const record = await ensureWorkbenchSeeded(c); const meta = await readSessionMeta(c, tabId); if (!meta || meta.closed) { throw new Error(`Unknown workbench session tab: ${tabId}`); } return buildSessionDetailFromMeta(record, meta); } export async function getTaskSummary(c: any): Promise { return await buildTaskSummary(c); } export async function getTaskDetail(c: any): Promise { return await buildTaskDetail(c); } export async function getSessionDetail(c: any, tabId: string): Promise { return await buildSessionDetail(c, tabId); } /** * Replaces the old notifyWorkbenchUpdated pattern. * * The task actor emits two kinds of updates: * - Push summary state up to the parent workspace actor so the sidebar * materialized projection stays current. * - Broadcast full detail/session payloads down to direct task subscribers. */ export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise { const workspace = await getOrCreateWorkspace(c, c.state.workspaceId); await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail: await buildTaskDetail(c), }); if (options?.sessionId) { c.broadcast("sessionUpdated", { type: "sessionUpdated", session: await buildSessionDetail(c, options.sessionId), }); } } export async function refreshWorkbenchDerivedState(c: any): Promise { const record = await ensureWorkbenchSeeded(c); const gitState = await collectWorkbenchGitState(c, record); await writeCachedGitState(c, gitState); await broadcastTaskUpdate(c); } export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise { const record = await ensureWorkbenchSeeded(c); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId)); if (!meta?.sandboxSessionId) { return; } const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId); await writeSessionTranscript(c, meta.tabId, transcript); await broadcastTaskUpdate(c, { sessionId: meta.tabId }); } export async function renameWorkbenchTask(c: any, value: string): Promise { const nextTitle = value.trim(); if (!nextTitle) { throw new Error("task title is required"); } await c.db .update(taskTable) .set({ title: nextTitle, updatedAt: Date.now(), }) .where(eq(taskTable.id, 1)) .run(); c.state.title = nextTitle; await broadcastTaskUpdate(c); } export async function renameWorkbenchBranch(c: any, value: string): Promise { const nextBranch = value.trim(); if (!nextBranch) { throw new Error("branch name is required"); } const record = await ensureWorkbenchSeeded(c); if (!record.branchName) { throw new Error("cannot rename branch before task branch exists"); } if (!record.activeSandboxId) { throw new Error("cannot rename branch without an active sandbox"); } const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; if (!activeSandbox?.cwd) { throw new Error("cannot rename branch without a sandbox cwd"); } const renameResult = await executeInSandbox(c, { sandboxId: record.activeSandboxId, cwd: activeSandbox.cwd, command: [ `git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`, `if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`, `git push origin ${JSON.stringify(nextBranch)}`, `git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`, ].join(" && "), label: `git branch -m ${record.branchName} ${nextBranch}`, }); if (renameResult.exitCode !== 0) { throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`); } await c.db .update(taskTable) .set({ branchName: nextBranch, updatedAt: Date.now(), }) .where(eq(taskTable.id, 1)) .run(); c.state.branchName = nextBranch; const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote); await project.registerTaskBranch({ taskId: c.state.taskId, branchName: nextBranch, }); await broadcastTaskUpdate(c); } export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> { const record = await ensureWorkbenchSeeded(c); if (record.activeSessionId) { const existingSessions = await listSessionMetaRows(c); if (existingSessions.length === 0) { await ensureSessionMeta(c, { tabId: record.activeSessionId, sandboxSessionId: record.activeSessionId, model: model ?? defaultModelForAgent(record.agentType), sessionName: "Session 1", status: "ready", }); await broadcastTaskUpdate(c, { sessionId: record.activeSessionId }); return { tabId: record.activeSessionId }; } } const tabId = `tab-${randomUUID()}`; await ensureSessionMeta(c, { tabId, model: model ?? defaultModelForAgent(record.agentType), status: record.activeSandboxId ? "pending_session_create" : "pending_provision", created: false, }); const providerId = record.providerId ?? c.state.providerId ?? getActorRuntimeContext().providers.defaultProviderId(); const self = selfTask(c); if (!record.activeSandboxId && !String(record.status ?? "").startsWith("init_")) { await self.send("task.command.provision", { providerId }, { wait: false }); } await self.send( "task.command.workbench.ensure_session", { tabId, ...(model ? { model } : {}) }, { wait: false, }, ); await broadcastTaskUpdate(c, { sessionId: tabId }); return { tabId }; } export async function ensureWorkbenchSession(c: any, tabId: string, model?: string): Promise { const meta = await readSessionMeta(c, tabId); if (!meta || meta.closed) { return; } const record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { await updateSessionMeta(c, tabId, { status: "pending_provision", errorMessage: null, }); return; } if (!meta.sandboxSessionId && record.activeSessionId && meta.status === "pending_provision") { const existingTabForActiveSession = await readSessionMetaBySandboxSessionId(c, record.activeSessionId); if (existingTabForActiveSession && existingTabForActiveSession.tabId !== tabId) { await updateSessionMeta(c, existingTabForActiveSession.tabId, { closed: 1, }); } await updateSessionMeta(c, tabId, { sandboxSessionId: record.activeSessionId, status: "ready", errorMessage: null, created: 1, }); await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId: record.activeSessionId, }); await broadcastTaskUpdate(c, { sessionId: tabId }); return; } if (meta.sandboxSessionId) { await updateSessionMeta(c, tabId, { status: "ready", errorMessage: null, }); await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId: meta.sandboxSessionId, }); await broadcastTaskUpdate(c, { sessionId: tabId }); return; } const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; if (!cwd) { await updateSessionMeta(c, tabId, { status: "error", errorMessage: "cannot create session without a sandbox cwd", }); await broadcastTaskUpdate(c, { sessionId: tabId }); return; } await updateSessionMeta(c, tabId, { status: "pending_session_create", errorMessage: null, }); try { const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); const created = await sandbox.createSession({ prompt: "", cwd, agent: agentTypeForModel(model ?? meta.model ?? defaultModelForAgent(record.agentType)), }); if (!created.id) { throw new Error(created.error ?? "sandbox-agent session creation failed"); } await updateSessionMeta(c, tabId, { sandboxSessionId: created.id, status: "ready", errorMessage: null, }); await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId: created.id, }); } catch (error) { await updateSessionMeta(c, tabId, { status: "error", errorMessage: error instanceof Error ? error.message : String(error), }); } await broadcastTaskUpdate(c, { sessionId: tabId }); } export async function enqueuePendingWorkbenchSessions(c: any): Promise { const self = selfTask(c); const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter( (row) => row.closed !== true && row.status !== "ready" && row.status !== "error", ); for (const row of pending) { await self.send( "task.command.workbench.ensure_session", { tabId: row.tabId, model: row.model, }, { wait: false, }, ); } } export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise { const trimmed = title.trim(); if (!trimmed) { throw new Error("session title is required"); } await updateSessionMeta(c, sessionId, { sessionName: trimmed, }); await broadcastTaskUpdate(c, { sessionId }); } export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise { await updateSessionMeta(c, sessionId, { unread: unread ? 1 : 0, }); await broadcastTaskUpdate(c, { sessionId }); } export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array): Promise { await updateSessionMeta(c, sessionId, { draftText: text, draftAttachmentsJson: JSON.stringify(attachments), draftUpdatedAt: Date.now(), }); await broadcastTaskUpdate(c, { sessionId }); } export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise { await updateSessionMeta(c, sessionId, { model, }); await broadcastTaskUpdate(c, { sessionId }); } export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array): Promise { const record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { throw new Error("cannot send message without an active sandbox"); } const meta = await requireReadySessionMeta(c, sessionId); const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); const prompt = [text.trim(), ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`)] .filter(Boolean) .join("\n\n"); if (!prompt) { throw new Error("message text is required"); } await sandbox.sendPrompt({ sessionId: meta.sandboxSessionId, prompt, notification: true, }); await updateSessionMeta(c, sessionId, { unread: 0, created: 1, draftText: "", draftAttachmentsJson: "[]", draftUpdatedAt: Date.now(), thinkingSinceMs: Date.now(), }); await c.db .update(taskRuntime) .set({ activeSessionId: meta.sandboxSessionId, updatedAt: Date.now(), }) .where(eq(taskRuntime.id, 1)) .run(); const sync = await getOrCreateTaskStatusSync(c, c.state.workspaceId, c.state.repoId, c.state.taskId, record.activeSandboxId, meta.sandboxSessionId, { workspaceId: c.state.workspaceId, repoId: c.state.repoId, taskId: c.state.taskId, providerId: c.state.providerId, sandboxId: record.activeSandboxId, sessionId: meta.sandboxSessionId, intervalMs: STATUS_SYNC_INTERVAL_MS, }); await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS }); await sync.start(); await sync.force(); await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId: meta.sandboxSessionId, }); await broadcastTaskUpdate(c, { sessionId }); } export async function stopWorkbenchSession(c: any, sessionId: string): Promise { const record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { return; } const meta = await requireReadySessionMeta(c, sessionId); const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); await sandbox.cancelSession({ sessionId: meta.sandboxSessionId }); await updateSessionMeta(c, sessionId, { thinkingSinceMs: null, }); await broadcastTaskUpdate(c, { sessionId }); } export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise { const record = await ensureWorkbenchSeeded(c); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { tabId: sessionId, sandboxSessionId: sessionId })); let changed = false; if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) { const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle"; if (record.status !== mappedStatus) { await c.db .update(taskTable) .set({ status: mappedStatus, updatedAt: at, }) .where(eq(taskTable.id, 1)) .run(); changed = true; } const statusMessage = `session:${status}`; if (record.statusMessage !== statusMessage) { await c.db .update(taskRuntime) .set({ statusMessage, updatedAt: at, }) .where(eq(taskRuntime.id, 1)) .run(); changed = true; } } if (status === "running") { if (!meta.thinkingSinceMs) { await updateSessionMeta(c, sessionId, { thinkingSinceMs: at, }); changed = true; } } else { if (meta.thinkingSinceMs) { await updateSessionMeta(c, sessionId, { thinkingSinceMs: null, }); changed = true; } if (!meta.unread && shouldMarkSessionUnreadForStatus(meta, status)) { await updateSessionMeta(c, sessionId, { unread: 1, }); changed = true; } } if (changed) { if (status !== "running") { await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { sessionId, }); await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); } await broadcastTaskUpdate(c, { sessionId: meta.tabId }); } } export async function closeWorkbenchSession(c: any, sessionId: string): Promise { const record = await ensureWorkbenchSeeded(c); const sessions = await listSessionMetaRows(c); if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) { return; } const meta = await readSessionMeta(c, sessionId); if (!meta) { return; } if (record.activeSandboxId && meta.sandboxSessionId) { const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); await sandbox.destroySession({ sessionId: meta.sandboxSessionId }); } await updateSessionMeta(c, sessionId, { closed: 1, thinkingSinceMs: null, }); if (record.activeSessionId === sessionId || record.activeSessionId === meta.sandboxSessionId) { await c.db .update(taskRuntime) .set({ activeSessionId: null, updatedAt: Date.now(), }) .where(eq(taskRuntime.id, 1)) .run(); } await broadcastTaskUpdate(c); } export async function markWorkbenchUnread(c: any): Promise { const sessions = await listSessionMetaRows(c); const latest = sessions[sessions.length - 1]; if (!latest) { return; } await updateSessionMeta(c, latest.tabId, { unread: 1, }); await broadcastTaskUpdate(c, { sessionId: latest.tabId }); } export async function publishWorkbenchPr(c: any): Promise { const record = await ensureWorkbenchSeeded(c); if (!record.branchName) { throw new Error("cannot publish PR without a branch"); } const { driver } = getActorRuntimeContext(); const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId); const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task, undefined, { githubToken: auth?.githubToken ?? null, }); await c.db .update(taskTable) .set({ prSubmitted: 1, updatedAt: Date.now(), }) .where(eq(taskTable.id, 1)) .run(); await broadcastTaskUpdate(c); } export async function revertWorkbenchFile(c: any, path: string): Promise { const record = await ensureWorkbenchSeeded(c); if (!record.activeSandboxId) { throw new Error("cannot revert file without an active sandbox"); } const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; if (!activeSandbox?.cwd) { throw new Error("cannot revert file without a sandbox cwd"); } const result = await executeInSandbox(c, { sandboxId: record.activeSandboxId, cwd: activeSandbox.cwd, command: `if git ls-files --error-unmatch -- ${JSON.stringify(path)} >/dev/null 2>&1; then git restore --staged --worktree -- ${JSON.stringify(path)} || git checkout -- ${JSON.stringify(path)}; else rm -f ${JSON.stringify(path)}; fi`, label: `git restore ${path}`, }); if (result.exitCode !== 0) { throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); } await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); await broadcastTaskUpdate(c); }