mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 00:03:04 +00:00
chore: recover bogota workspace state
This commit is contained in:
parent
5d65013aa5
commit
e08d1b4dca
436 changed files with 172093 additions and 455 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
65
factory/packages/client/src/app-client.ts
Normal file
65
factory/packages/client/src/app-client.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type {
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
FactoryOrganization,
|
||||
FactoryUser,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getMockFactoryAppClient } from "./mock-app.js";
|
||||
import { createRemoteFactoryAppClient } from "./remote/app-client.js";
|
||||
|
||||
export interface FactoryAppClient {
|
||||
getSnapshot(): FactoryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void>;
|
||||
triggerRepoImport(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void>;
|
||||
cancelScheduledRenewal(organizationId: string): Promise<void>;
|
||||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateFactoryAppClientOptions {
|
||||
mode: "mock" | "remote";
|
||||
backend?: BackendClient;
|
||||
}
|
||||
|
||||
export function createFactoryAppClient(options: CreateFactoryAppClientOptions): FactoryAppClient {
|
||||
if (options.mode === "mock") {
|
||||
return getMockFactoryAppClient() as unknown as FactoryAppClient;
|
||||
}
|
||||
if (!options.backend) {
|
||||
throw new Error("Remote app client requires a backend client");
|
||||
}
|
||||
return createRemoteFactoryAppClient({ backend: options.backend });
|
||||
}
|
||||
|
||||
export function currentFactoryUser(snapshot: FactoryAppSnapshot): FactoryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
export function currentFactoryOrganization(snapshot: FactoryAppSnapshot): FactoryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleFactoryOrganizations(snapshot: FactoryAppSnapshot): FactoryOrganization[] {
|
||||
const user = currentFactoryUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
|
||||
|
|
@ -3,6 +3,8 @@ import type {
|
|||
AgentType,
|
||||
AddRepoInput,
|
||||
AppConfig,
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
CreateHandoffInput,
|
||||
HandoffRecord,
|
||||
HandoffSummary,
|
||||
|
|
@ -25,8 +27,9 @@ import type {
|
|||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
RepoRecord,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
SwitchResult
|
||||
} from "@openhandoff/shared";
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
|
||||
|
||||
export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill";
|
||||
|
|
@ -125,6 +128,17 @@ export interface BackendMetadata {
|
|||
}
|
||||
|
||||
export interface BackendClient {
|
||||
getAppSnapshot(): Promise<FactoryAppSnapshot>;
|
||||
signInWithGithub(userId?: string): Promise<FactoryAppSnapshot>;
|
||||
signOutApp(): Promise<FactoryAppSnapshot>;
|
||||
selectAppOrganization(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<FactoryAppSnapshot>;
|
||||
triggerAppRepoImport(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
completeAppHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<FactoryAppSnapshot>;
|
||||
cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
resumeAppSubscription(organizationId: string): Promise<FactoryAppSnapshot>;
|
||||
recordAppSeatUsage(workspaceId: string): Promise<FactoryAppSnapshot>;
|
||||
addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord>;
|
||||
listRepos(workspaceId: string): Promise<RepoRecord[]>;
|
||||
createHandoff(input: CreateHandoffInput): Promise<HandoffRecord>;
|
||||
|
|
@ -347,14 +361,52 @@ async function probeMetadataEndpoint(
|
|||
|
||||
export function createBackendClient(options: BackendClientOptions): BackendClient {
|
||||
let clientPromise: Promise<RivetClient> | null = null;
|
||||
let appSessionId =
|
||||
typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-factory:remote-app-session") : null;
|
||||
const workbenchSubscriptions = new Map<
|
||||
string,
|
||||
{
|
||||
listeners: Set<() => void>;
|
||||
disposeConnPromise: Promise<(() => Promise<void>) | null> | null;
|
||||
pollInterval: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
>();
|
||||
|
||||
const persistAppSessionId = (nextSessionId: string | null): void => {
|
||||
appSessionId = nextSessionId;
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (nextSessionId) {
|
||||
window.localStorage.setItem("sandbox-agent-factory:remote-app-session", nextSessionId);
|
||||
} else {
|
||||
window.localStorage.removeItem("sandbox-agent-factory:remote-app-session");
|
||||
}
|
||||
};
|
||||
|
||||
const appRequest = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (appSessionId) {
|
||||
headers.set("x-factory-session", appSessionId);
|
||||
}
|
||||
if (init?.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
const nextSessionId = res.headers.get("x-factory-session");
|
||||
if (nextSessionId) {
|
||||
persistAppSessionId(nextSessionId);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(`app request failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
const getClient = async (): Promise<RivetClient> => {
|
||||
if (clientPromise) {
|
||||
return clientPromise;
|
||||
|
|
@ -373,6 +425,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
requestTimeoutMs: 8_000
|
||||
});
|
||||
|
||||
const isBrowserRuntime = typeof window !== "undefined";
|
||||
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
|
||||
const candidateEndpoint = metadata.clientEndpoint
|
||||
? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin)
|
||||
|
|
@ -380,10 +433,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
// If the manager port isn't reachable from this client (common behind reverse proxies),
|
||||
// fall back to the configured serverless endpoint to avoid hanging requests.
|
||||
const shouldUseCandidate = metadata.clientEndpoint
|
||||
const shouldUseCandidate = metadata.clientEndpoint && !isBrowserRuntime
|
||||
? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500)
|
||||
: true;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
|
||||
: false;
|
||||
const resolvedEndpoint = isBrowserRuntime
|
||||
? options.endpoint
|
||||
: shouldUseCandidate
|
||||
? candidateEndpoint
|
||||
: options.endpoint;
|
||||
|
||||
return createClient({
|
||||
endpoint: resolvedEndpoint,
|
||||
|
|
@ -480,13 +537,27 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
entry = {
|
||||
listeners: new Set(),
|
||||
disposeConnPromise: null,
|
||||
pollInterval: null,
|
||||
};
|
||||
workbenchSubscriptions.set(workspaceId, entry);
|
||||
}
|
||||
|
||||
entry.listeners.add(listener);
|
||||
|
||||
if (!entry.disposeConnPromise) {
|
||||
const isBrowserRuntime = typeof window !== "undefined";
|
||||
if (isBrowserRuntime) {
|
||||
if (!entry.pollInterval) {
|
||||
entry.pollInterval = setInterval(() => {
|
||||
const current = workbenchSubscriptions.get(workspaceId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
for (const currentListener of [...current.listeners]) {
|
||||
currentListener();
|
||||
}
|
||||
}, 1_000);
|
||||
}
|
||||
} else if (!entry.disposeConnPromise) {
|
||||
entry.disposeConnPromise = (async () => {
|
||||
const handle = await workspace(workspaceId);
|
||||
const conn = (handle as any).connect();
|
||||
|
|
@ -519,6 +590,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
}
|
||||
|
||||
workbenchSubscriptions.delete(workspaceId);
|
||||
if (current.pollInterval) {
|
||||
clearInterval(current.pollInterval);
|
||||
current.pollInterval = null;
|
||||
}
|
||||
void current.disposeConnPromise?.then(async (disposeConn) => {
|
||||
await disposeConn?.();
|
||||
});
|
||||
|
|
@ -526,6 +601,80 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
};
|
||||
|
||||
return {
|
||||
async getAppSnapshot(): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>("/app/snapshot");
|
||||
},
|
||||
|
||||
async signInWithGithub(userId?: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>("/app/sign-in", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userId ? { userId } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async signOutApp(): Promise<FactoryAppSnapshot> {
|
||||
const snapshot = await appRequest<FactoryAppSnapshot>("/app/sign-out", { method: "POST" });
|
||||
persistAppSessionId(appSessionId);
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
async selectAppOrganization(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/select`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async updateAppOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${input.organizationId}/profile`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async triggerAppRepoImport(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async reconnectAppGithub(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/reconnect`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async completeAppHostedCheckout(
|
||||
organizationId: string,
|
||||
planId: FactoryBillingPlanId,
|
||||
): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/checkout`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ planId }),
|
||||
});
|
||||
},
|
||||
|
||||
async cancelAppScheduledRenewal(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async resumeAppSubscription(organizationId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/organizations/${organizationId}/billing/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async recordAppSeatUsage(workspaceId: string): Promise<FactoryAppSnapshot> {
|
||||
return await appRequest<FactoryAppSnapshot>(`/app/workspaces/${workspaceId}/seat-usage`, {
|
||||
method: "POST",
|
||||
});
|
||||
},
|
||||
|
||||
async addRepo(workspaceId: string, remoteUrl: string): Promise<RepoRecord> {
|
||||
return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl });
|
||||
},
|
||||
|
|
|
|||
1
factory/packages/client/src/backend.ts
Normal file
1
factory/packages/client/src/backend.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./backend-client.js";
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from "./app-client.js";
|
||||
export * from "./backend-client.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./mock-app.js";
|
||||
export * from "./view-model.js";
|
||||
export * from "./workbench-client.js";
|
||||
|
|
|
|||
598
factory/packages/client/src/mock-app.ts
Normal file
598
factory/packages/client/src/mock-app.ts
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
import { injectMockLatency } from "./mock/latency.js";
|
||||
|
||||
export type MockBillingPlanId = "free" | "team" | "enterprise";
|
||||
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
|
||||
export type MockRepoImportStatus = "ready" | "not_started" | "importing";
|
||||
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
|
||||
export type MockOrganizationKind = "personal" | "organization";
|
||||
|
||||
export interface MockFactoryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
githubLogin: string;
|
||||
roleLabel: string;
|
||||
eligibleOrganizationIds: string[];
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganizationMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
state: "active" | "invited";
|
||||
}
|
||||
|
||||
export interface MockFactoryInvoice {
|
||||
id: string;
|
||||
label: string;
|
||||
issuedAt: string;
|
||||
amountUsd: number;
|
||||
status: "paid" | "open";
|
||||
}
|
||||
|
||||
export interface MockFactoryBillingState {
|
||||
planId: MockBillingPlanId;
|
||||
status: MockBillingStatus;
|
||||
seatsIncluded: number;
|
||||
trialEndsAt: string | null;
|
||||
renewalAt: string | null;
|
||||
stripeCustomerId: string;
|
||||
paymentMethodLabel: string;
|
||||
invoices: MockFactoryInvoice[];
|
||||
}
|
||||
|
||||
export interface MockFactoryGithubState {
|
||||
connectedAccount: string;
|
||||
installationStatus: MockGithubInstallationStatus;
|
||||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganizationSettings {
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
seatAccrualMode: "first_prompt";
|
||||
defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
||||
autoImportRepos: boolean;
|
||||
}
|
||||
|
||||
export interface MockFactoryOrganization {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: MockOrganizationKind;
|
||||
settings: MockFactoryOrganizationSettings;
|
||||
github: MockFactoryGithubState;
|
||||
billing: MockFactoryBillingState;
|
||||
members: MockFactoryOrganizationMember[];
|
||||
seatAssignments: string[];
|
||||
repoImportStatus: MockRepoImportStatus;
|
||||
repoCatalog: string[];
|
||||
}
|
||||
|
||||
export interface MockFactoryAppSnapshot {
|
||||
auth: {
|
||||
status: "signed_out" | "signed_in";
|
||||
currentUserId: string | null;
|
||||
};
|
||||
activeOrganizationId: string | null;
|
||||
users: MockFactoryUser[];
|
||||
organizations: MockFactoryOrganization[];
|
||||
}
|
||||
|
||||
export interface UpdateMockOrganizationProfileInput {
|
||||
organizationId: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
}
|
||||
|
||||
export interface MockFactoryAppClient {
|
||||
getSnapshot(): MockFactoryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
|
||||
triggerRepoImport(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
|
||||
cancelScheduledRenewal(organizationId: string): Promise<void>;
|
||||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "sandbox-agent-factory:mock-app:v1";
|
||||
|
||||
function isoDate(daysFromNow: number): string {
|
||||
const value = new Date();
|
||||
value.setDate(value.getDate() + daysFromNow);
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function buildDefaultSnapshot(): MockFactoryAppSnapshot {
|
||||
return {
|
||||
auth: {
|
||||
status: "signed_out",
|
||||
currentUserId: null,
|
||||
},
|
||||
activeOrganizationId: null,
|
||||
users: [
|
||||
{
|
||||
id: "user-nathan",
|
||||
name: "Nathan",
|
||||
email: "nathan@acme.dev",
|
||||
githubLogin: "nathan",
|
||||
roleLabel: "Founder",
|
||||
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
|
||||
},
|
||||
{
|
||||
id: "user-maya",
|
||||
name: "Maya",
|
||||
email: "maya@acme.dev",
|
||||
githubLogin: "maya",
|
||||
roleLabel: "Staff Engineer",
|
||||
eligibleOrganizationIds: ["acme"],
|
||||
},
|
||||
{
|
||||
id: "user-jamie",
|
||||
name: "Jamie",
|
||||
email: "jamie@rivet.dev",
|
||||
githubLogin: "jamie",
|
||||
roleLabel: "Platform Lead",
|
||||
eligibleOrganizationIds: ["personal-jamie", "rivet"],
|
||||
},
|
||||
],
|
||||
organizations: [
|
||||
{
|
||||
id: "personal-nathan",
|
||||
workspaceId: "personal-nathan",
|
||||
kind: "personal",
|
||||
settings: {
|
||||
displayName: "Nathan",
|
||||
slug: "nathan",
|
||||
primaryDomain: "personal",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-sonnet-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "nathan",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced just now",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
status: "active",
|
||||
seatsIncluded: 1,
|
||||
trialEndsAt: null,
|
||||
renewalAt: null,
|
||||
stripeCustomerId: "cus_mock_personal_nathan",
|
||||
paymentMethodLabel: "No card required",
|
||||
invoices: [],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
|
||||
],
|
||||
seatAssignments: ["nathan@acme.dev"],
|
||||
repoImportStatus: "ready",
|
||||
repoCatalog: ["nathan/personal-site"],
|
||||
},
|
||||
{
|
||||
id: "acme",
|
||||
workspaceId: "acme",
|
||||
kind: "organization",
|
||||
settings: {
|
||||
displayName: "Acme",
|
||||
slug: "acme",
|
||||
primaryDomain: "acme.dev",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-sonnet-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "acme",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 3,
|
||||
lastSyncLabel: "Synced 4 minutes ago",
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
status: "active",
|
||||
seatsIncluded: 5,
|
||||
trialEndsAt: null,
|
||||
renewalAt: isoDate(18),
|
||||
stripeCustomerId: "cus_mock_acme_team",
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
invoices: [
|
||||
{ id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" },
|
||||
{ id: "inv-acme-000", label: "February 2026", issuedAt: "2026-02-01", amountUsd: 240, status: "paid" },
|
||||
],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
|
||||
{ id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
|
||||
{ id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
|
||||
{ id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" },
|
||||
],
|
||||
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
|
||||
},
|
||||
{
|
||||
id: "rivet",
|
||||
workspaceId: "rivet",
|
||||
kind: "organization",
|
||||
settings: {
|
||||
displayName: "Rivet",
|
||||
slug: "rivet",
|
||||
primaryDomain: "rivet.dev",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "o3",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "rivet-dev",
|
||||
installationStatus: "reconnect_required",
|
||||
importedRepoCount: 4,
|
||||
lastSyncLabel: "Sync stalled 2 hours ago",
|
||||
},
|
||||
billing: {
|
||||
planId: "enterprise",
|
||||
status: "trialing",
|
||||
seatsIncluded: 25,
|
||||
trialEndsAt: isoDate(12),
|
||||
renewalAt: isoDate(12),
|
||||
stripeCustomerId: "cus_mock_rivet_enterprise",
|
||||
paymentMethodLabel: "ACH verified",
|
||||
invoices: [{ id: "inv-rivet-001", label: "Enterprise pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
|
||||
},
|
||||
members: [
|
||||
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
|
||||
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
|
||||
{ id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
|
||||
],
|
||||
seatAssignments: ["jamie@rivet.dev"],
|
||||
repoImportStatus: "not_started",
|
||||
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
|
||||
},
|
||||
{
|
||||
id: "personal-jamie",
|
||||
workspaceId: "personal-jamie",
|
||||
kind: "personal",
|
||||
settings: {
|
||||
displayName: "Jamie",
|
||||
slug: "jamie",
|
||||
primaryDomain: "personal",
|
||||
seatAccrualMode: "first_prompt",
|
||||
defaultModel: "claude-opus-4",
|
||||
autoImportRepos: true,
|
||||
},
|
||||
github: {
|
||||
connectedAccount: "jamie",
|
||||
installationStatus: "connected",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced yesterday",
|
||||
},
|
||||
billing: {
|
||||
planId: "free",
|
||||
status: "active",
|
||||
seatsIncluded: 1,
|
||||
trialEndsAt: null,
|
||||
renewalAt: null,
|
||||
stripeCustomerId: "cus_mock_personal_jamie",
|
||||
paymentMethodLabel: "No card required",
|
||||
invoices: [],
|
||||
},
|
||||
members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }],
|
||||
seatAssignments: ["jamie@rivet.dev"],
|
||||
repoImportStatus: "ready",
|
||||
repoCatalog: ["jamie/demo-app"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredSnapshot(): MockFactoryAppSnapshot | null {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as MockFactoryAppSnapshot;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot(snapshot: MockFactoryAppSnapshot): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
function planSeatsIncluded(planId: MockBillingPlanId): number {
|
||||
switch (planId) {
|
||||
case "free":
|
||||
return 1;
|
||||
case "team":
|
||||
return 5;
|
||||
case "enterprise":
|
||||
return 25;
|
||||
}
|
||||
}
|
||||
|
||||
class MockFactoryAppStore implements MockFactoryAppClient {
|
||||
private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot();
|
||||
private listeners = new Set<() => void>();
|
||||
private importTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
getSnapshot(): MockFactoryAppSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async signInWithGithub(userId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const user = this.snapshot.users.find((candidate) => candidate.id === userId);
|
||||
if (!user) {
|
||||
throw new Error(`Unknown mock user ${userId}`);
|
||||
}
|
||||
|
||||
this.updateSnapshot((current) => {
|
||||
const activeOrganizationId =
|
||||
user.eligibleOrganizationIds.length === 1 ? user.eligibleOrganizationIds[0] ?? null : null;
|
||||
return {
|
||||
...current,
|
||||
auth: {
|
||||
status: "signed_in",
|
||||
currentUserId: userId,
|
||||
},
|
||||
activeOrganizationId,
|
||||
};
|
||||
});
|
||||
|
||||
if (user.eligibleOrganizationIds.length === 1) {
|
||||
await this.selectOrganization(user.eligibleOrganizationIds[0]!);
|
||||
}
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
auth: {
|
||||
status: "signed_out",
|
||||
currentUserId: null,
|
||||
},
|
||||
activeOrganizationId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const org = this.requireOrganization(organizationId);
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
activeOrganizationId: organizationId,
|
||||
}));
|
||||
|
||||
if (org.repoImportStatus !== "ready") {
|
||||
await this.triggerRepoImport(organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(input.organizationId);
|
||||
this.updateOrganization(input.organizationId, (organization) => ({
|
||||
...organization,
|
||||
settings: {
|
||||
...organization.settings,
|
||||
displayName: input.displayName.trim() || organization.settings.displayName,
|
||||
slug: input.slug.trim() || organization.settings.slug,
|
||||
primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async triggerRepoImport(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
const existingTimer = this.importTimers.get(organizationId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
repoImportStatus: "importing",
|
||||
github: {
|
||||
...organization.github,
|
||||
lastSyncLabel: "Importing repository catalog...",
|
||||
},
|
||||
}));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
repoImportStatus: "ready",
|
||||
github: {
|
||||
...organization.github,
|
||||
importedRepoCount: organization.repoCatalog.length,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Synced just now",
|
||||
},
|
||||
}));
|
||||
this.importTimers.delete(organizationId);
|
||||
}, 1_250);
|
||||
|
||||
this.importTimers.set(organizationId, timer);
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
planId,
|
||||
status: "active",
|
||||
seatsIncluded: planSeatsIncluded(planId),
|
||||
trialEndsAt: null,
|
||||
renewalAt: isoDate(30),
|
||||
paymentMethodLabel: planId === "enterprise" ? "ACH verified" : "Visa ending in 4242",
|
||||
invoices: [
|
||||
{
|
||||
id: `inv-${organizationId}-${Date.now()}`,
|
||||
label: `${organization.settings.displayName} ${planId} upgrade`,
|
||||
issuedAt: new Date().toISOString().slice(0, 10),
|
||||
amountUsd: planId === "team" ? 240 : planId === "enterprise" ? 1200 : 0,
|
||||
status: "paid",
|
||||
},
|
||||
...organization.billing.invoices,
|
||||
],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "scheduled_cancel",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
status: "active",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
installationStatus: "connected",
|
||||
lastSyncLabel: "Reconnected just now",
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
recordSeatUsage(workspaceId: string): void {
|
||||
const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId);
|
||||
const currentUser = currentMockUser(this.snapshot);
|
||||
if (!org || !currentUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (org.seatAssignments.includes(currentUser.email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateOrganization(org.id, (organization) => ({
|
||||
...organization,
|
||||
seatAssignments: [...organization.seatAssignments, currentUser.email],
|
||||
}));
|
||||
}
|
||||
|
||||
private injectAsyncLatency(): Promise<void> {
|
||||
return injectMockLatency();
|
||||
}
|
||||
|
||||
private updateOrganization(
|
||||
organizationId: string,
|
||||
updater: (organization: MockFactoryOrganization) => MockFactoryOrganization,
|
||||
): void {
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
organizations: current.organizations.map((organization) =>
|
||||
organization.id === organizationId ? updater(organization) : organization,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateSnapshot(updater: (current: MockFactoryAppSnapshot) => MockFactoryAppSnapshot): void {
|
||||
this.snapshot = updater(this.snapshot);
|
||||
saveSnapshot(this.snapshot);
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private requireOrganization(organizationId: string): MockFactoryOrganization {
|
||||
const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(`Unknown mock organization ${organizationId}`);
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
function currentMockUser(snapshot: MockFactoryAppSnapshot): MockFactoryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
const mockFactoryAppStore = new MockFactoryAppStore();
|
||||
|
||||
export function getMockFactoryAppClient(): MockFactoryAppClient {
|
||||
return mockFactoryAppStore;
|
||||
}
|
||||
|
||||
export function currentMockFactoryUser(snapshot: MockFactoryAppSnapshot): MockFactoryUser | null {
|
||||
return currentMockUser(snapshot);
|
||||
}
|
||||
|
||||
export function currentMockFactoryOrganization(snapshot: MockFactoryAppSnapshot): MockFactoryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleMockOrganizations(snapshot: MockFactoryAppSnapshot): MockFactoryOrganization[] {
|
||||
const user = currentMockUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
13
factory/packages/client/src/mock/latency.ts
Normal file
13
factory/packages/client/src/mock/latency.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
const MOCK_LATENCY_MIN_MS = 1;
|
||||
const MOCK_LATENCY_MAX_MS = 200;
|
||||
|
||||
export function randomMockLatencyMs(): number {
|
||||
return Math.floor(Math.random() * (MOCK_LATENCY_MAX_MS - MOCK_LATENCY_MIN_MS + 1)) + MOCK_LATENCY_MIN_MS;
|
||||
}
|
||||
|
||||
export function injectMockLatency(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, randomMockLatencyMs());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
slugify,
|
||||
uid,
|
||||
} from "../workbench-model.js";
|
||||
import { getMockFactoryAppClient } from "../mock-app.js";
|
||||
import { injectMockLatency } from "./latency.js";
|
||||
import type {
|
||||
HandoffWorkbenchAddTabResponse,
|
||||
HandoffWorkbenchChangeModelInput,
|
||||
|
|
@ -26,7 +28,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 +50,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;
|
||||
}
|
||||
|
|
@ -64,6 +70,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise<HandoffWorkbenchCreateHandoffResponse> {
|
||||
await this.injectAsyncLatency();
|
||||
const id = uid();
|
||||
const tabId = `session-${id}`;
|
||||
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
|
||||
|
|
@ -103,10 +110,22 @@ 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 };
|
||||
}
|
||||
|
||||
async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => {
|
||||
const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null;
|
||||
if (!targetTab) {
|
||||
|
|
@ -121,6 +140,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameHandoff(input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`);
|
||||
|
|
@ -129,6 +149,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameBranch(input: HandoffWorkbenchRenameInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`);
|
||||
|
|
@ -137,10 +158,12 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() }));
|
||||
}
|
||||
|
||||
async publishPr(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1;
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
||||
...handoff,
|
||||
|
|
@ -149,7 +172,16 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}));
|
||||
}
|
||||
|
||||
async pushHandoff(input: HandoffWorkbenchSelectInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => ({
|
||||
...handoff,
|
||||
updatedAtMs: nowMs(),
|
||||
}));
|
||||
}
|
||||
|
||||
async revertFile(input: HandoffWorkbenchDiffInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (handoff) => {
|
||||
const file = handoff.fileChanges.find((entry) => entry.path === input.path);
|
||||
const nextDiffs = { ...handoff.diffs };
|
||||
|
|
@ -185,6 +217,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const text = input.text.trim();
|
||||
if (!text) {
|
||||
throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`);
|
||||
|
|
@ -192,11 +225,15 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
|
||||
this.assertTab(input.handoffId, input.tabId);
|
||||
const startedAtMs = nowMs();
|
||||
getMockFactoryAppClient().recordSeatUsage(this.snapshot.workspaceId);
|
||||
|
||||
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,
|
||||
|
|
@ -286,6 +323,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async stopAgent(input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.assertTab(input.handoffId, input.tabId);
|
||||
const existing = this.pendingTimers.get(input.tabId);
|
||||
if (existing) {
|
||||
|
|
@ -309,6 +347,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => ({
|
||||
...currentHandoff,
|
||||
tabs: currentHandoff.tabs.map((candidate) =>
|
||||
|
|
@ -318,6 +357,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const title = input.title.trim();
|
||||
if (!title) {
|
||||
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
|
||||
|
|
@ -331,6 +371,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async closeTab(input: HandoffWorkbenchTabInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateHandoff(input.handoffId, (currentHandoff) => {
|
||||
if (currentHandoff.tabs.length <= 1) {
|
||||
return currentHandoff;
|
||||
|
|
@ -344,6 +385,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async addTab(input: HandoffWorkbenchSelectInput): Promise<HandoffWorkbenchAddTabResponse> {
|
||||
await this.injectAsyncLatency();
|
||||
this.assertHandoff(input.handoffId);
|
||||
const nextTab: AgentTab = {
|
||||
id: uid(),
|
||||
|
|
@ -368,6 +410,7 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async changeModel(input: HandoffWorkbenchChangeModelInput): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model));
|
||||
if (!group) {
|
||||
throw new Error(`Unable to resolve model provider for ${input.model}`);
|
||||
|
|
@ -428,6 +471,10 @@ class MockWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
private injectAsyncLatency(): Promise<void> {
|
||||
return injectMockLatency();
|
||||
}
|
||||
}
|
||||
|
||||
function candidateEventIndex(handoff: Handoff, tabId: string): number {
|
||||
|
|
@ -435,11 +482,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;
|
||||
}
|
||||
|
|
|
|||
138
factory/packages/client/src/remote/app-client.ts
Normal file
138
factory/packages/client/src/remote/app-client.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type {
|
||||
FactoryAppSnapshot,
|
||||
FactoryBillingPlanId,
|
||||
UpdateFactoryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/factory-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import type { FactoryAppClient } from "../app-client.js";
|
||||
|
||||
export interface RemoteFactoryAppClientOptions {
|
||||
backend: BackendClient;
|
||||
}
|
||||
|
||||
class RemoteFactoryAppStore implements FactoryAppClient {
|
||||
private readonly backend: BackendClient;
|
||||
private snapshot: FactoryAppSnapshot = {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private importPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: RemoteFactoryAppClientOptions) {
|
||||
this.backend = options.backend;
|
||||
}
|
||||
|
||||
getSnapshot(): FactoryAppSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
void this.refresh();
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async signInWithGithub(userId?: string): Promise<void> {
|
||||
this.snapshot = await this.backend.signInWithGithub(userId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
this.snapshot = await this.backend.signOutApp();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateFactoryOrganizationProfileInput): Promise<void> {
|
||||
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async triggerRepoImport(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: FactoryBillingPlanId): Promise<void> {
|
||||
this.snapshot = await this.backend.completeAppHostedCheckout(organizationId, planId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async cancelScheduledRenewal(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.cancelAppScheduledRenewal(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async resumeSubscription(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.resumeAppSubscription(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async reconnectGithub(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.reconnectAppGithub(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.recordAppSeatUsage(workspaceId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private scheduleImportPollingIfNeeded(): void {
|
||||
if (this.importPollTimeout) {
|
||||
clearTimeout(this.importPollTimeout);
|
||||
this.importPollTimeout = null;
|
||||
}
|
||||
|
||||
if (!this.snapshot.organizations.some((organization) => organization.repoImportStatus === "importing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.importPollTimeout = setTimeout(() => {
|
||||
this.importPollTimeout = null;
|
||||
void this.refresh();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.refreshPromise) {
|
||||
await this.refreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
this.snapshot = await this.backend.getAppSnapshot();
|
||||
this.notify();
|
||||
this.scheduleImportPollingIfNeeded();
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteFactoryAppClient(
|
||||
options: RemoteFactoryAppClientOptions,
|
||||
): FactoryAppClient {
|
||||
return new RemoteFactoryAppStore(options);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -104,6 +109,7 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient {
|
|||
}
|
||||
|
||||
async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise<void> {
|
||||
await this.backend.recordAppSeatUsage(this.workspaceId);
|
||||
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,15 +913,221 @@ export function buildInitialHandoffs(): Handoff[] {
|
|||
];
|
||||
}
|
||||
|
||||
export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot {
|
||||
const repos: WorkbenchRepo[] = [
|
||||
{ id: "acme-backend", label: "acme/backend" },
|
||||
{ id: "acme-frontend", label: "acme/frontend" },
|
||||
{ id: "acme-infra", label: "acme/infra" },
|
||||
function buildPersonalHandoffs(ownerName: string, repoId: string, repoName: string): Handoff[] {
|
||||
return [
|
||||
{
|
||||
id: "h-personal-1",
|
||||
repoId,
|
||||
title: "Polish onboarding copy",
|
||||
status: "idle",
|
||||
repoName,
|
||||
updatedAtMs: minutesAgo(18),
|
||||
branch: "feat/onboarding-copy",
|
||||
pullRequest: null,
|
||||
tabs: [
|
||||
{
|
||||
id: "personal-t1",
|
||||
sessionId: "personal-t1",
|
||||
sessionName: "Landing page copy",
|
||||
agent: "Claude",
|
||||
model: "claude-sonnet-4",
|
||||
status: "idle",
|
||||
thinkingSinceMs: null,
|
||||
unread: false,
|
||||
created: true,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: transcriptFromLegacyMessages("personal-t1", [
|
||||
{
|
||||
id: "pm1",
|
||||
role: "user",
|
||||
agent: null,
|
||||
createdAtMs: minutesAgo(22),
|
||||
lines: [`Tighten the hero copy and call-to-action for ${ownerName}'s landing page.`],
|
||||
},
|
||||
{
|
||||
id: "pm2",
|
||||
role: "agent",
|
||||
agent: "claude",
|
||||
createdAtMs: minutesAgo(20),
|
||||
lines: [
|
||||
"Updated the hero copy to focus on speed-to-handoff and clearer user outcomes.",
|
||||
"",
|
||||
"I also adjusted the primary CTA to feel more action-oriented.",
|
||||
],
|
||||
durationMs: 11_000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
fileChanges: [
|
||||
{ path: "src/content/home.ts", added: 12, removed: 6, type: "M" },
|
||||
{ path: "src/components/Hero.tsx", added: 8, removed: 3, type: "M" },
|
||||
],
|
||||
diffs: {
|
||||
"src/content/home.ts": [
|
||||
"@@ -1,6 +1,9 @@",
|
||||
"-export const heroHeadline = 'Build AI handoffs faster';",
|
||||
"+export const heroHeadline = 'Ship clean handoffs without the chaos';",
|
||||
" export const heroBody = [",
|
||||
"- 'OpenHandoff keeps context, diffs, and follow-up work in one place.',",
|
||||
"+ 'Review work, keep context, and hand tasks across your team without losing the thread.',",
|
||||
"+ 'Everything stays attached to the repo, the branch, and the transcript.',",
|
||||
" ];",
|
||||
].join("\n"),
|
||||
},
|
||||
fileTree: [
|
||||
{
|
||||
name: "src",
|
||||
path: "src",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "components",
|
||||
path: "src/components",
|
||||
isDir: true,
|
||||
children: [{ name: "Hero.tsx", path: "src/components/Hero.tsx", isDir: false }],
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
path: "src/content",
|
||||
isDir: true,
|
||||
children: [{ name: "home.ts", path: "src/content/home.ts", isDir: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const handoffs = buildInitialHandoffs();
|
||||
}
|
||||
|
||||
function buildRivetHandoffs(): Handoff[] {
|
||||
return [
|
||||
{
|
||||
id: "rivet-h1",
|
||||
repoId: "rivet-dashboard",
|
||||
title: "Add billing upgrade affordances",
|
||||
status: "running",
|
||||
repoName: "rivet/dashboard",
|
||||
updatedAtMs: minutesAgo(6),
|
||||
branch: "feat/billing-upgrade-affordances",
|
||||
pullRequest: { number: 183, status: "draft" },
|
||||
tabs: [
|
||||
{
|
||||
id: "rivet-t1",
|
||||
sessionId: "rivet-t1",
|
||||
sessionName: "Upgrade surface",
|
||||
agent: "Codex",
|
||||
model: "o3",
|
||||
status: "running",
|
||||
thinkingSinceMs: minutesAgo(1),
|
||||
unread: false,
|
||||
created: true,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: transcriptFromLegacyMessages("rivet-t1", [
|
||||
{
|
||||
id: "rm1",
|
||||
role: "user",
|
||||
agent: null,
|
||||
createdAtMs: minutesAgo(8),
|
||||
lines: ["Add an upgrade CTA on the usage banner and thread it into the hosted checkout flow."],
|
||||
},
|
||||
{
|
||||
id: "rm2",
|
||||
role: "agent",
|
||||
agent: "codex",
|
||||
createdAtMs: minutesAgo(7),
|
||||
lines: ["I'm wiring the banner CTA to the checkout route and cleaning up the plan comparison copy."],
|
||||
durationMs: 16_000,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
fileChanges: [
|
||||
{ path: "src/routes/settings/billing.tsx", added: 34, removed: 8, type: "M" },
|
||||
{ path: "src/components/usage-banner.tsx", added: 12, removed: 0, type: "A" },
|
||||
],
|
||||
diffs: {
|
||||
"src/routes/settings/billing.tsx": [
|
||||
"@@ -14,7 +14,13 @@",
|
||||
" export function BillingSettings() {",
|
||||
"- return <EmptyState />;",
|
||||
"+ return (",
|
||||
"+ <>",
|
||||
"+ <UsageBanner ctaLabel=\"Upgrade with Stripe\" />",
|
||||
"+ <PlanMatrix />",
|
||||
"+ </>",
|
||||
"+ );",
|
||||
" }",
|
||||
].join("\n"),
|
||||
},
|
||||
fileTree: [
|
||||
{
|
||||
name: "src",
|
||||
path: "src",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "components",
|
||||
path: "src/components",
|
||||
isDir: true,
|
||||
children: [{ name: "usage-banner.tsx", path: "src/components/usage-banner.tsx", isDir: false }],
|
||||
},
|
||||
{
|
||||
name: "routes",
|
||||
path: "src/routes",
|
||||
isDir: true,
|
||||
children: [
|
||||
{
|
||||
name: "settings",
|
||||
path: "src/routes/settings",
|
||||
isDir: true,
|
||||
children: [{ name: "billing.tsx", path: "src/routes/settings/billing.tsx", isDir: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildInitialMockLayoutViewModel(workspaceId = "default"): HandoffWorkbenchSnapshot {
|
||||
let repos: WorkbenchRepo[];
|
||||
let handoffs: Handoff[];
|
||||
|
||||
switch (workspaceId) {
|
||||
case "personal-nathan":
|
||||
repos = [{ id: "nathan-personal-site", label: "nathan/personal-site" }];
|
||||
handoffs = buildPersonalHandoffs("Nathan", "nathan-personal-site", "nathan/personal-site");
|
||||
break;
|
||||
case "personal-jamie":
|
||||
repos = [{ id: "jamie-demo-app", label: "jamie/demo-app" }];
|
||||
handoffs = buildPersonalHandoffs("Jamie", "jamie-demo-app", "jamie/demo-app");
|
||||
break;
|
||||
case "rivet":
|
||||
repos = [
|
||||
{ id: "rivet-dashboard", label: "rivet/dashboard" },
|
||||
{ id: "rivet-agents", label: "rivet/agents" },
|
||||
{ id: "rivet-billing", label: "rivet/billing" },
|
||||
{ id: "rivet-infrastructure", label: "rivet/infrastructure" },
|
||||
];
|
||||
handoffs = buildRivetHandoffs();
|
||||
break;
|
||||
case "acme":
|
||||
case "default":
|
||||
default:
|
||||
repos = [
|
||||
{ id: "acme-backend", label: "acme/backend" },
|
||||
{ id: "acme-frontend", label: "acme/frontend" },
|
||||
{ id: "acme-infra", label: "acme/infra" },
|
||||
];
|
||||
handoffs = buildInitialHandoffs();
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
workspaceId: "default",
|
||||
workspaceId,
|
||||
repos,
|
||||
projects: groupWorkbenchProjects(repos, handoffs),
|
||||
handoffs,
|
||||
|
|
@ -960,6 +1166,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);
|
||||
}
|
||||
|
|
|
|||
1
factory/packages/client/src/workbench.ts
Normal file
1
factory/packages/client/src/workbench.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./workbench-client.js";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
128
factory/packages/client/test/workbench-client.test.ts
Normal file
128
factory/packages/client/test/workbench-client.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue