feat(factory): finish workbench milestone pass

This commit is contained in:
Nathan Flurry 2026-03-09 16:34:27 -07:00
parent bf282199b5
commit 49cba9e6c2
137 changed files with 819 additions and 338 deletions

View file

@ -26,7 +26,7 @@ import type {
RepoStackActionResult,
RepoRecord,
SwitchResult
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";

View file

@ -0,0 +1 @@
export * from "./backend-client.js";

View file

@ -26,7 +26,7 @@ import type {
WorkbenchAgentTab as AgentTab,
WorkbenchHandoff as Handoff,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
function buildTranscriptEvent(params: {
@ -48,10 +48,14 @@ function buildTranscriptEvent(params: {
}
class MockWorkbenchStore implements HandoffWorkbenchClient {
private snapshot = buildInitialMockLayoutViewModel();
private snapshot: HandoffWorkbenchSnapshot;
private listeners = new Set<() => void>();
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(workspaceId: string) {
this.snapshot = buildInitialMockLayoutViewModel(workspaceId);
}
getSnapshot(): HandoffWorkbenchSnapshot {
return this.snapshot;
}
@ -103,6 +107,17 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
...current,
handoffs: [nextHandoff, ...current.handoffs],
}));
const task = input.task.trim();
if (task) {
await this.sendMessage({
handoffId: id,
tabId,
text: task,
attachments: [],
});
}
return { handoffId: id, tabId };
}
@ -149,6 +164,13 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
}));
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => ({
...handoff,
updatedAtMs: nowMs(),
}));
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
this.updateHandoff(input.handoffId, (handoff) => {
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
@ -195,8 +217,11 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
this.updateHandoff(input.handoffId, (currentHandoff) => {
const isFirstOnHandoff = currentHandoff.status === "new";
const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title;
const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch;
const synthesizedTitle = text.length > 50 ? `${text.slice(0, 47)}...` : text;
const newTitle =
isFirstOnHandoff && currentHandoff.title === "New Handoff" ? synthesizedTitle : currentHandoff.title;
const newBranch =
isFirstOnHandoff && !currentHandoff.branch ? `feat/${slugify(synthesizedTitle)}` : currentHandoff.branch;
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
const userEvent = buildTranscriptEvent({
sessionId: input.tabId,
@ -435,11 +460,13 @@ function candidateEventIndex(handoff: Handoff, tabId: string): number {
return (tab?.transcript.length ?? 0) + 1;
}
let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null;
const mockWorkbenchClients = new Map<string, HandoffWorkbenchClient>();
export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient {
if (!sharedMockWorkbenchClient) {
sharedMockWorkbenchClient = new MockWorkbenchStore();
export function getMockWorkbenchClient(workspaceId = "default"): HandoffWorkbenchClient {
let client = mockWorkbenchClients.get(workspaceId);
if (!client) {
client = new MockWorkbenchStore(workspaceId);
mockWorkbenchClients.set(workspaceId, client);
}
return sharedMockWorkbenchClient;
return client;
}

View file

@ -12,7 +12,7 @@ import type {
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import type { BackendClient } from "../backend-client.js";
import { groupWorkbenchProjects } from "../workbench-model.js";
import type { HandoffWorkbenchClient } from "../workbench-client.js";
@ -93,6 +93,11 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
await this.refresh();
}
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
await this.backend.runAction(this.workspaceId, input.handoffId, "push");
await this.refresh();
}
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
await this.backend.revertWorkbenchFile(this.workspaceId, input);
await this.refresh();

View file

@ -1,4 +1,4 @@
import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared";
import type { HandoffRecord, HandoffStatus } from "@sandbox-agent/factory-shared";
export const HANDOFF_STATUS_GROUPS = [
"queued",

View file

@ -12,9 +12,9 @@ import type {
HandoffWorkbenchSnapshot,
HandoffWorkbenchTabInput,
HandoffWorkbenchUpdateDraftInput,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import type { BackendClient } from "./backend-client.js";
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
import { getMockWorkbenchClient } from "./mock/workbench-client.js";
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
export type HandoffWorkbenchClientMode = "mock" | "remote";
@ -34,6 +34,7 @@ export interface HandoffWorkbenchClient {
renameBranch(input: HandoffWorkbenchRenameInput): Promise<void>;
archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
publishPr(input: HandoffWorkbenchSelectInput): Promise<void>;
pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void>;
revertFile(input: HandoffWorkbenchDiffInput): Promise<void>;
updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise<void>;
sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void>;
@ -49,7 +50,7 @@ export function createHandoffWorkbenchClient(
options: CreateHandoffWorkbenchClientOptions,
): HandoffWorkbenchClient {
if (options.mode === "mock") {
return getSharedMockWorkbenchClient();
return getMockWorkbenchClient(options.workspaceId);
}
if (!options.backend) {

View file

@ -12,7 +12,7 @@ import type {
WorkbenchProjectSection,
WorkbenchRepo,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
export const MODEL_GROUPS: ModelGroup[] = [
{
@ -913,7 +913,7 @@ export function buildInitialHandoffs(): Handoff[] {
];
}
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot {
const repos: WorkbenchRepo[] = [
{ id: "acme-backend", label: "acme/backend" },
{ id: "acme-frontend", label: "acme/frontend" },
@ -921,7 +921,7 @@ export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
];
const handoffs = buildInitialHandoffs();
return {
workspaceId: "default",
workspaceId,
repos,
projects: groupWorkbenchProjects(repos, handoffs),
handoffs,
@ -960,6 +960,5 @@ export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff
updatedAtMs:
project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs,
}))
.filter((project) => project.handoffs.length > 0)
.sort((a, b) => b.updatedAtMs - a.updatedAtMs);
}

View file

@ -0,0 +1 @@
export * from "./workbench-client.js";