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

@ -1,12 +1,43 @@
{
"name": "@openhandoff/client",
"name": "@sandbox-agent/factory-client",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./backend": {
"types": "./dist/backend.d.ts",
"import": "./dist/backend.js"
},
"./workbench": {
"types": "./dist/workbench.d.ts",
"import": "./dist/workbench.js"
},
"./view-model": {
"types": "./dist/view-model.d.ts",
"import": "./dist/view-model.js"
}
},
"typesVersions": {
"*": {
"backend": [
"dist/backend.d.ts"
],
"view-model": [
"dist/view-model.d.ts"
],
"workbench": [
"dist/workbench.d.ts"
]
}
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"build": "tsup src/index.ts src/backend.ts src/workbench.ts src/view-model.ts --format esm --dts",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts",
@ -14,7 +45,7 @@
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
},
"dependencies": {
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit"
},
"devDependencies": {

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";

View file

@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { describe, expect, it } from "vitest";
import type { HistoryEvent, RepoOverview } from "@openhandoff/shared";
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord, HistoryEvent } from "@openhandoff/shared";
import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";

View file

@ -1,13 +1,15 @@
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type {
HandoffRecord,
HandoffWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
@ -21,6 +23,10 @@ function requiredEnv(name: string): string {
return value;
}
function requiredRepoRemote(): string {
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
const value = process.env[name]?.trim();
switch (value) {
@ -38,14 +44,66 @@ async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise<void> {
const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`;
function backendPortFromEndpoint(endpoint: string): string {
const url = new URL(endpoint);
if (url.port) {
return url.port;
}
return url.protocol === "https:" ? "443" : "80";
}
async function resolveBackendContainerName(endpoint: string): Promise<string | null> {
const explicit = process.env.HF_E2E_BACKEND_CONTAINER?.trim();
if (explicit) {
if (explicit.toLowerCase() === "host") {
return null;
}
return explicit;
}
const { stdout } = await execFileAsync("docker", [
"ps",
"--filter",
`publish=${backendPortFromEndpoint(endpoint)}`,
"--format",
"{{.Names}}",
]);
const containerName = stdout
.split("\n")
.map((line) => line.trim())
.find(Boolean);
return containerName ?? null;
}
function sandboxRepoPath(record: HandoffRecord): string {
const activeSandbox =
record.sandboxes.find((sandbox) => sandbox.sandboxId === record.activeSandboxId) ??
record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0);
const cwd = activeSandbox?.cwd?.trim();
if (!cwd) {
throw new Error(`No sandbox cwd is available for handoff ${record.handoffId}`);
}
return cwd;
}
async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise<void> {
const repoPath = sandboxRepoPath(record);
const containerName = await resolveBackendContainerName(endpoint);
if (!containerName) {
const directory =
filePath.includes("/") ? `${repoPath}/${filePath.slice(0, filePath.lastIndexOf("/"))}` : repoPath;
await mkdir(directory, { recursive: true });
await writeFile(`${repoPath}/${filePath}`, `${content}\n`, "utf8");
return;
}
const script = [
`cd ${JSON.stringify(repoPath)}`,
`mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`,
`printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`,
].join(" && ");
await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]);
await execFileAsync("docker", ["exec", containerName, "bash", "-lc", script]);
}
async function poll<T>(
@ -166,7 +224,7 @@ describe("e2e(client): workbench flows", () => {
const endpoint =
process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const runId = `wb-${Date.now().toString(36)}`;
const expectedFile = `${runId}.txt`;
@ -215,7 +273,8 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId);
const detail = await client.getHandoff(workspaceId, created.handoffId);
await seedSandboxFile(endpoint, detail, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",

View file

@ -5,7 +5,7 @@ import type {
WorkbenchHandoff,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
@ -18,6 +18,10 @@ function requiredEnv(name: string): string {
return value;
}
function requiredRepoRemote(): string {
return process.env.HF_E2E_REPO_REMOTE?.trim() || requiredEnv("HF_E2E_GITHUB_REPO");
}
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
const value = process.env[name]?.trim();
switch (value) {
@ -196,7 +200,7 @@ describe("e2e(client): workbench load", () => {
async () => {
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@openhandoff/shared";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import {
filterHandoffs,
formatRelativeAge,

View file

@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import type { BackendClient } from "../src/backend-client.js";
import { createHandoffWorkbenchClient } from "../src/workbench-client.js";
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("createHandoffWorkbenchClient", () => {
it("scopes mock clients by workspace", async () => {
const alpha = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-alpha",
});
const beta = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-beta",
});
const alphaInitial = alpha.getSnapshot();
const betaInitial = beta.getSnapshot();
expect(alphaInitial.workspaceId).toBe("mock-alpha");
expect(betaInitial.workspaceId).toBe("mock-beta");
await alpha.createHandoff({
repoId: alphaInitial.repos[0]!.id,
task: "Ship alpha-only change",
title: "Alpha only",
});
expect(alpha.getSnapshot().handoffs).toHaveLength(alphaInitial.handoffs.length + 1);
expect(beta.getSnapshot().handoffs).toHaveLength(betaInitial.handoffs.length);
});
it("uses the initial task to bootstrap a new mock handoff session", async () => {
const client = createHandoffWorkbenchClient({
mode: "mock",
workspaceId: "mock-onboarding",
});
const snapshot = client.getSnapshot();
const created = await client.createHandoff({
repoId: snapshot.repos[0]!.id,
task: "Reply with exactly: MOCK_WORKBENCH_READY",
title: "Mock onboarding",
branch: "feat/mock-onboarding",
model: "gpt-4o",
});
const runningHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
expect(runningHandoff).toEqual(
expect.objectContaining({
title: "Mock onboarding",
branch: "feat/mock-onboarding",
status: "running",
}),
);
expect(runningHandoff?.tabs[0]).toEqual(
expect.objectContaining({
id: created.tabId,
created: true,
status: "running",
}),
);
expect(runningHandoff?.tabs[0]?.transcript).toEqual([
expect.objectContaining({
sender: "client",
payload: expect.objectContaining({
method: "session/prompt",
}),
}),
]);
await sleep(2_700);
const completedHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
expect(completedHandoff?.status).toBe("idle");
expect(completedHandoff?.tabs[0]).toEqual(
expect.objectContaining({
status: "idle",
unread: true,
}),
);
expect(completedHandoff?.tabs[0]?.transcript).toEqual([
expect.objectContaining({ sender: "client" }),
expect.objectContaining({ sender: "agent" }),
]);
});
it("routes remote push actions through the backend boundary", async () => {
const actions: Array<{ workspaceId: string; handoffId: string; action: string }> = [];
let snapshotReads = 0;
const backend = {
async runAction(workspaceId: string, handoffId: string, action: string): Promise<void> {
actions.push({ workspaceId, handoffId, action });
},
async getWorkbench(workspaceId: string) {
snapshotReads += 1;
return {
workspaceId,
repos: [],
projects: [],
handoffs: [],
};
},
subscribeWorkbench(): () => void {
return () => {};
},
} as unknown as BackendClient;
const client = createHandoffWorkbenchClient({
mode: "remote",
backend,
workspaceId: "remote-ws",
});
await client.pushHandoff({ handoffId: "handoff-123" });
expect(actions).toEqual([
{
workspaceId: "remote-ws",
handoffId: "handoff-123",
action: "push",
},
]);
expect(snapshotReads).toBe(1);
});
});