mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 16:03:43 +00:00
Rename Foundry handoffs to tasks (#239)
* Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks
This commit is contained in:
parent
d30cc0bcc8
commit
d75e8c31d1
281 changed files with 9242 additions and 4356 deletions
24
foundry/packages/client/package.json
Normal file
24
foundry/packages/client/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@sandbox-agent/foundry-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.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",
|
||||
"test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts",
|
||||
"test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"rivetkit": "2.1.6",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.0"
|
||||
}
|
||||
}
|
||||
67
foundry/packages/client/src/app-client.ts
Normal file
67
foundry/packages/client/src/app-client.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type {
|
||||
FoundryAppSnapshot,
|
||||
FoundryBillingPlanId,
|
||||
FoundryOrganization,
|
||||
FoundryUser,
|
||||
UpdateFoundryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getMockFoundryAppClient } from "./mock-app.js";
|
||||
import { createRemoteFoundryAppClient } from "./remote/app-client.js";
|
||||
|
||||
export interface FoundryAppClient {
|
||||
getSnapshot(): FoundryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId?: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
skipStarterRepo(): Promise<void>;
|
||||
starStarterRepo(organizationId: string): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void>;
|
||||
triggerGithubSync(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void>;
|
||||
openBillingPortal(organizationId: string): Promise<void>;
|
||||
cancelScheduledRenewal(organizationId: string): Promise<void>;
|
||||
resumeSubscription(organizationId: string): Promise<void>;
|
||||
reconnectGithub(organizationId: string): Promise<void>;
|
||||
recordSeatUsage(workspaceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateFoundryAppClientOptions {
|
||||
mode: "mock" | "remote";
|
||||
backend?: BackendClient;
|
||||
}
|
||||
|
||||
export function createFoundryAppClient(options: CreateFoundryAppClientOptions): FoundryAppClient {
|
||||
if (options.mode === "mock") {
|
||||
return getMockFoundryAppClient() as unknown as FoundryAppClient;
|
||||
}
|
||||
if (!options.backend) {
|
||||
throw new Error("Remote app client requires a backend client");
|
||||
}
|
||||
return createRemoteFoundryAppClient({ backend: options.backend });
|
||||
}
|
||||
|
||||
export function currentFoundryUser(snapshot: FoundryAppSnapshot): FoundryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
export function currentFoundryOrganization(snapshot: FoundryAppSnapshot): FoundryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleFoundryOrganizations(snapshot: FoundryAppSnapshot): FoundryOrganization[] {
|
||||
const user = currentFoundryUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
1063
foundry/packages/client/src/backend-client.ts
Normal file
1063
foundry/packages/client/src/backend-client.ts
Normal file
File diff suppressed because it is too large
Load diff
6
foundry/packages/client/src/index.ts
Normal file
6
foundry/packages/client/src/index.ts
Normal file
|
|
@ -0,0 +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";
|
||||
34
foundry/packages/client/src/keys.ts
Normal file
34
foundry/packages/client/src/keys.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
export type ActorKey = string[];
|
||||
|
||||
export function workspaceKey(workspaceId: string): ActorKey {
|
||||
return ["ws", workspaceId];
|
||||
}
|
||||
|
||||
export function projectKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId];
|
||||
}
|
||||
|
||||
export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "task", taskId];
|
||||
}
|
||||
|
||||
export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey {
|
||||
return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "history"];
|
||||
}
|
||||
|
||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
||||
}
|
||||
|
||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||
return ["ws", workspaceId, "project", repoId, "branch-sync"];
|
||||
}
|
||||
|
||||
export function taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey {
|
||||
// Include sandbox + session so multiple sandboxes/sessions can be tracked per task.
|
||||
return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId];
|
||||
}
|
||||
697
foundry/packages/client/src/mock-app.ts
Normal file
697
foundry/packages/client/src/mock-app.ts
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
import { injectMockLatency } from "./mock/latency.js";
|
||||
|
||||
export type MockBillingPlanId = "free" | "team";
|
||||
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
|
||||
export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required";
|
||||
export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error";
|
||||
export type MockOrganizationKind = "personal" | "organization";
|
||||
export type MockStarterRepoStatus = "pending" | "starred" | "skipped";
|
||||
|
||||
export interface MockFoundryUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
githubLogin: string;
|
||||
roleLabel: string;
|
||||
eligibleOrganizationIds: string[];
|
||||
}
|
||||
|
||||
export interface MockFoundryOrganizationMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "owner" | "admin" | "member";
|
||||
state: "active" | "invited";
|
||||
}
|
||||
|
||||
export interface MockFoundryInvoice {
|
||||
id: string;
|
||||
label: string;
|
||||
issuedAt: string;
|
||||
amountUsd: number;
|
||||
status: "paid" | "open";
|
||||
}
|
||||
|
||||
export interface MockFoundryBillingState {
|
||||
planId: MockBillingPlanId;
|
||||
status: MockBillingStatus;
|
||||
seatsIncluded: number;
|
||||
trialEndsAt: string | null;
|
||||
renewalAt: string | null;
|
||||
stripeCustomerId: string;
|
||||
paymentMethodLabel: string;
|
||||
invoices: MockFoundryInvoice[];
|
||||
}
|
||||
|
||||
export interface MockFoundryGithubState {
|
||||
connectedAccount: string;
|
||||
installationStatus: MockGithubInstallationStatus;
|
||||
syncStatus: MockGithubSyncStatus;
|
||||
importedRepoCount: number;
|
||||
lastSyncLabel: string;
|
||||
lastSyncAt: number | null;
|
||||
}
|
||||
|
||||
export interface MockFoundryOrganizationSettings {
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
seatAccrualMode: "first_prompt";
|
||||
defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
|
||||
autoImportRepos: boolean;
|
||||
}
|
||||
|
||||
export interface MockFoundryOrganization {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
kind: MockOrganizationKind;
|
||||
settings: MockFoundryOrganizationSettings;
|
||||
github: MockFoundryGithubState;
|
||||
billing: MockFoundryBillingState;
|
||||
members: MockFoundryOrganizationMember[];
|
||||
seatAssignments: string[];
|
||||
repoCatalog: string[];
|
||||
}
|
||||
|
||||
export interface MockFoundryAppSnapshot {
|
||||
auth: {
|
||||
status: "signed_out" | "signed_in";
|
||||
currentUserId: string | null;
|
||||
};
|
||||
activeOrganizationId: string | null;
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: string;
|
||||
repoUrl: string;
|
||||
status: MockStarterRepoStatus;
|
||||
starredAt: number | null;
|
||||
skippedAt: number | null;
|
||||
};
|
||||
};
|
||||
users: MockFoundryUser[];
|
||||
organizations: MockFoundryOrganization[];
|
||||
}
|
||||
|
||||
export interface UpdateMockOrganizationProfileInput {
|
||||
organizationId: string;
|
||||
displayName: string;
|
||||
slug: string;
|
||||
primaryDomain: string;
|
||||
}
|
||||
|
||||
export interface MockFoundryAppClient {
|
||||
getSnapshot(): MockFoundryAppSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
signInWithGithub(userId: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
skipStarterRepo(): Promise<void>;
|
||||
starStarterRepo(organizationId: string): Promise<void>;
|
||||
selectOrganization(organizationId: string): Promise<void>;
|
||||
updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise<void>;
|
||||
triggerGithubSync(organizationId: string): Promise<void>;
|
||||
completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise<void>;
|
||||
openBillingPortal(organizationId: string): 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-foundry:mock-app:v1";
|
||||
|
||||
function isoDate(daysFromNow: number): string {
|
||||
const value = new Date();
|
||||
value.setDate(value.getDate() + daysFromNow);
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
|
||||
switch (value) {
|
||||
case "ready":
|
||||
case "synced":
|
||||
return "synced";
|
||||
case "importing":
|
||||
case "syncing":
|
||||
return "syncing";
|
||||
case "error":
|
||||
return "error";
|
||||
default:
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||
return {
|
||||
auth: {
|
||||
status: "signed_out",
|
||||
currentUserId: null,
|
||||
},
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: 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",
|
||||
syncStatus: "synced",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.now() - 60_000,
|
||||
},
|
||||
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"],
|
||||
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",
|
||||
syncStatus: "pending",
|
||||
importedRepoCount: 3,
|
||||
lastSyncLabel: "Waiting for first import",
|
||||
lastSyncAt: null,
|
||||
},
|
||||
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"],
|
||||
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",
|
||||
syncStatus: "error",
|
||||
importedRepoCount: 4,
|
||||
lastSyncLabel: "Sync stalled 2 hours ago",
|
||||
lastSyncAt: Date.now() - 2 * 60 * 60_000,
|
||||
},
|
||||
billing: {
|
||||
planId: "team",
|
||||
status: "trialing",
|
||||
seatsIncluded: 5,
|
||||
trialEndsAt: isoDate(12),
|
||||
renewalAt: isoDate(12),
|
||||
stripeCustomerId: "cus_mock_rivet_team",
|
||||
paymentMethodLabel: "Visa ending in 4242",
|
||||
invoices: [{ id: "inv-rivet-001", label: "Team 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"],
|
||||
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",
|
||||
syncStatus: "synced",
|
||||
importedRepoCount: 1,
|
||||
lastSyncLabel: "Synced yesterday",
|
||||
lastSyncAt: Date.now() - 24 * 60 * 60_000,
|
||||
},
|
||||
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"],
|
||||
repoCatalog: ["jamie/demo-app"],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function parseStoredSnapshot(): MockFoundryAppSnapshot | 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 MockFoundryAppSnapshot & {
|
||||
organizations?: Array<MockFoundryOrganization & { repoImportStatus?: string }>;
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: parsed.onboarding?.starterRepo?.repoFullName ?? "rivet-dev/sandbox-agent",
|
||||
repoUrl: parsed.onboarding?.starterRepo?.repoUrl ?? "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: parsed.onboarding?.starterRepo?.status ?? "pending",
|
||||
starredAt: parsed.onboarding?.starterRepo?.starredAt ?? null,
|
||||
skippedAt: parsed.onboarding?.starterRepo?.skippedAt ?? null,
|
||||
},
|
||||
},
|
||||
organizations: (parsed.organizations ?? []).map((organization: MockFoundryOrganization & { repoImportStatus?: string }) => ({
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
|
||||
lastSyncAt: organization.github?.lastSyncAt ?? null,
|
||||
},
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot(snapshot: MockFoundryAppSnapshot): 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;
|
||||
}
|
||||
}
|
||||
|
||||
class MockFoundryAppStore implements MockFoundryAppClient {
|
||||
private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot();
|
||||
private listeners = new Set<() => void>();
|
||||
private importTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
getSnapshot(): MockFoundryAppSnapshot {
|
||||
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,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
...current.onboarding.starterRepo,
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async skipStarterRepo(): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
...current.onboarding.starterRepo,
|
||||
status: "skipped",
|
||||
skippedAt: Date.now(),
|
||||
starredAt: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async starStarterRepo(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
this.requireOrganization(organizationId);
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
...current.onboarding.starterRepo,
|
||||
status: "starred",
|
||||
starredAt: Date.now(),
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
const org = this.requireOrganization(organizationId);
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
activeOrganizationId: organizationId,
|
||||
}));
|
||||
|
||||
if (org.github.syncStatus !== "synced") {
|
||||
await this.triggerGithubSync(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 triggerGithubSync(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,
|
||||
github: {
|
||||
...organization.github,
|
||||
syncStatus: "syncing",
|
||||
lastSyncLabel: "Syncing repositories...",
|
||||
},
|
||||
}));
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.updateOrganization(organizationId, (organization) => ({
|
||||
...organization,
|
||||
github: {
|
||||
...organization.github,
|
||||
importedRepoCount: organization.repoCatalog.length,
|
||||
installationStatus: "connected",
|
||||
syncStatus: "synced",
|
||||
lastSyncLabel: "Synced just now",
|
||||
lastSyncAt: Date.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: "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 : 0,
|
||||
status: "paid",
|
||||
},
|
||||
...organization.billing.invoices,
|
||||
],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
async openBillingPortal(_organizationId: string): Promise<void> {
|
||||
await this.injectAsyncLatency();
|
||||
}
|
||||
|
||||
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",
|
||||
syncStatus: "pending",
|
||||
lastSyncLabel: "Reconnected just now",
|
||||
lastSyncAt: Date.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: MockFoundryOrganization) => MockFoundryOrganization): void {
|
||||
this.updateSnapshot((current) => ({
|
||||
...current,
|
||||
organizations: current.organizations.map((organization) => (organization.id === organizationId ? updater(organization) : organization)),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateSnapshot(updater: (current: MockFoundryAppSnapshot) => MockFoundryAppSnapshot): void {
|
||||
this.snapshot = updater(this.snapshot);
|
||||
saveSnapshot(this.snapshot);
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private requireOrganization(organizationId: string): MockFoundryOrganization {
|
||||
const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId);
|
||||
if (!organization) {
|
||||
throw new Error(`Unknown mock organization ${organizationId}`);
|
||||
}
|
||||
return organization;
|
||||
}
|
||||
}
|
||||
|
||||
function currentMockUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
|
||||
if (!snapshot.auth.currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null;
|
||||
}
|
||||
|
||||
const mockFoundryAppStore = new MockFoundryAppStore();
|
||||
|
||||
export function getMockFoundryAppClient(): MockFoundryAppClient {
|
||||
return mockFoundryAppStore;
|
||||
}
|
||||
|
||||
export function currentMockFoundryUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null {
|
||||
return currentMockUser(snapshot);
|
||||
}
|
||||
|
||||
export function currentMockFoundryOrganization(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization | null {
|
||||
if (!snapshot.activeOrganizationId) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null;
|
||||
}
|
||||
|
||||
export function eligibleMockOrganizations(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization[] {
|
||||
const user = currentMockUser(snapshot);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eligible = new Set(user.eligibleOrganizationIds);
|
||||
return snapshot.organizations.filter((organization) => eligible.has(organization.id));
|
||||
}
|
||||
540
foundry/packages/client/src/mock/backend-client.ts
Normal file
540
foundry/packages/client/src/mock/backend-client.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import type {
|
||||
AddRepoInput,
|
||||
CreateTaskInput,
|
||||
FoundryAppSnapshot,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchCreateTaskResponse,
|
||||
TaskWorkbenchDiffInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
HistoryEvent,
|
||||
HistoryQueryInput,
|
||||
ProviderId,
|
||||
RepoOverview,
|
||||
RepoRecord,
|
||||
RepoStackActionInput,
|
||||
RepoStackActionResult,
|
||||
StarSandboxAgentRepoResult,
|
||||
SwitchResult,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
|
||||
import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
|
||||
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
|
||||
|
||||
interface MockProcessRecord extends SandboxProcessRecord {
|
||||
logText: string;
|
||||
}
|
||||
|
||||
function notSupported(name: string): never {
|
||||
throw new Error(`${name} is not supported by the mock backend client.`);
|
||||
}
|
||||
|
||||
function encodeBase64Utf8(value: string): string {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
return globalThis.btoa(unescape(encodeURIComponent(value)));
|
||||
}
|
||||
|
||||
function nowMs(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function mockRepoRemote(label: string): string {
|
||||
return `https://example.test/${label}.git`;
|
||||
}
|
||||
|
||||
function mockCwd(repoLabel: string, taskId: string): string {
|
||||
return `/mock/${repoLabel.replace(/\//g, "-")}/${taskId}`;
|
||||
}
|
||||
|
||||
function unsupportedAppSnapshot(): FoundryAppSnapshot {
|
||||
return {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
}
|
||||
|
||||
function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskRecord["status"] {
|
||||
if (archived) {
|
||||
return "archived";
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export function createMockBackendClient(defaultWorkspaceId = "default"): BackendClient {
|
||||
const workbench = getSharedMockWorkbenchClient();
|
||||
const listenersBySandboxId = new Map<string, Set<() => void>>();
|
||||
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
|
||||
let nextPid = 4000;
|
||||
let nextProcessId = 1;
|
||||
|
||||
const requireTask = (taskId: string) => {
|
||||
const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Unknown mock task ${taskId}`);
|
||||
}
|
||||
return task;
|
||||
};
|
||||
|
||||
const ensureProcessList = (sandboxId: string): MockProcessRecord[] => {
|
||||
const existing = processesBySandboxId.get(sandboxId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const created: MockProcessRecord[] = [];
|
||||
processesBySandboxId.set(sandboxId, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const notifySandbox = (sandboxId: string): void => {
|
||||
const listeners = listenersBySandboxId.get(sandboxId);
|
||||
if (!listeners) {
|
||||
return;
|
||||
}
|
||||
for (const listener of [...listeners]) {
|
||||
listener();
|
||||
}
|
||||
};
|
||||
|
||||
const buildTaskRecord = (taskId: string): TaskRecord => {
|
||||
const task = requireTask(taskId);
|
||||
const cwd = mockCwd(task.repoName, task.id);
|
||||
const archived = task.status === "archived";
|
||||
return {
|
||||
workspaceId: defaultWorkspaceId,
|
||||
repoId: task.repoId,
|
||||
repoRemote: mockRepoRemote(task.repoName),
|
||||
taskId: task.id,
|
||||
branchName: task.branch,
|
||||
title: task.title,
|
||||
task: task.title,
|
||||
providerId: "local",
|
||||
status: toTaskStatus(archived ? "archived" : "running", archived),
|
||||
statusMessage: archived ? "archived" : "mock sandbox ready",
|
||||
activeSandboxId: task.id,
|
||||
activeSessionId: task.tabs[0]?.sessionId ?? null,
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: task.id,
|
||||
providerId: "local",
|
||||
sandboxActorId: "mock-sandbox",
|
||||
switchTarget: `mock://${task.id}`,
|
||||
cwd,
|
||||
createdAt: task.updatedAtMs,
|
||||
updatedAt: task.updatedAtMs,
|
||||
},
|
||||
],
|
||||
agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude",
|
||||
prSubmitted: Boolean(task.pullRequest),
|
||||
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
|
||||
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
|
||||
prAuthor: task.pullRequest ? "mock" : null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: "0",
|
||||
hasUnpushed: task.fileChanges.length > 0 ? "1" : "0",
|
||||
parentBranch: null,
|
||||
createdAt: task.updatedAtMs,
|
||||
updatedAt: task.updatedAtMs,
|
||||
};
|
||||
};
|
||||
|
||||
const cloneProcess = (process: MockProcessRecord): MockProcessRecord => ({ ...process });
|
||||
|
||||
const createProcessRecord = (sandboxId: string, cwd: string, request: ProcessCreateRequest): MockProcessRecord => {
|
||||
const processId = `proc_${nextProcessId++}`;
|
||||
const createdAtMs = nowMs();
|
||||
const args = request.args ?? [];
|
||||
const interactive = request.interactive ?? false;
|
||||
const tty = request.tty ?? false;
|
||||
const statusLine = interactive && tty ? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n" : "Mock process created.\n";
|
||||
const commandLine = `$ ${[request.command, ...args].join(" ").trim()}\n`;
|
||||
return {
|
||||
id: processId,
|
||||
command: request.command,
|
||||
args,
|
||||
createdAtMs,
|
||||
cwd: request.cwd ?? cwd,
|
||||
exitCode: null,
|
||||
exitedAtMs: null,
|
||||
interactive,
|
||||
pid: nextPid++,
|
||||
status: "running",
|
||||
tty,
|
||||
logText: `${statusLine}${commandLine}`,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async getAppSnapshot(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async signInWithGithub(): Promise<void> {
|
||||
notSupported("signInWithGithub");
|
||||
},
|
||||
|
||||
async signOutApp(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async skipAppStarterRepo(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async starAppStarterRepo(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async selectAppOrganization(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async updateAppOrganizationProfile(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async triggerAppRepoImport(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async reconnectAppGithub(): Promise<void> {
|
||||
notSupported("reconnectAppGithub");
|
||||
},
|
||||
|
||||
async completeAppHostedCheckout(): Promise<void> {
|
||||
notSupported("completeAppHostedCheckout");
|
||||
},
|
||||
|
||||
async openAppBillingPortal(): Promise<void> {
|
||||
notSupported("openAppBillingPortal");
|
||||
},
|
||||
|
||||
async cancelAppScheduledRenewal(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async resumeAppSubscription(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async recordAppSeatUsage(): Promise<FoundryAppSnapshot> {
|
||||
return unsupportedAppSnapshot();
|
||||
},
|
||||
|
||||
async addRepo(_workspaceId: string, _remoteUrl: string): Promise<RepoRecord> {
|
||||
notSupported("addRepo");
|
||||
},
|
||||
|
||||
async listRepos(_workspaceId: string): Promise<RepoRecord[]> {
|
||||
return workbench.getSnapshot().repos.map((repo) => ({
|
||||
workspaceId: defaultWorkspaceId,
|
||||
repoId: repo.id,
|
||||
remoteUrl: mockRepoRemote(repo.label),
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs(),
|
||||
}));
|
||||
},
|
||||
|
||||
async createTask(_input: CreateTaskInput): Promise<TaskRecord> {
|
||||
notSupported("createTask");
|
||||
},
|
||||
|
||||
async listTasks(_workspaceId: string, repoId?: string): Promise<TaskSummary[]> {
|
||||
return workbench
|
||||
.getSnapshot()
|
||||
.tasks.filter((task) => !repoId || task.repoId === repoId)
|
||||
.map((task) => ({
|
||||
workspaceId: defaultWorkspaceId,
|
||||
repoId: task.repoId,
|
||||
taskId: task.id,
|
||||
branchName: task.branch,
|
||||
title: task.title,
|
||||
status: task.status === "archived" ? "archived" : "running",
|
||||
updatedAt: task.updatedAtMs,
|
||||
}));
|
||||
},
|
||||
|
||||
async getRepoOverview(_workspaceId: string, _repoId: string): Promise<RepoOverview> {
|
||||
notSupported("getRepoOverview");
|
||||
},
|
||||
|
||||
async runRepoStackAction(_input: RepoStackActionInput): Promise<RepoStackActionResult> {
|
||||
notSupported("runRepoStackAction");
|
||||
},
|
||||
|
||||
async getTask(_workspaceId: string, taskId: string): Promise<TaskRecord> {
|
||||
return buildTaskRecord(taskId);
|
||||
},
|
||||
|
||||
async listHistory(_input: HistoryQueryInput): Promise<HistoryEvent[]> {
|
||||
return [];
|
||||
},
|
||||
|
||||
async switchTask(_workspaceId: string, taskId: string): Promise<SwitchResult> {
|
||||
return {
|
||||
workspaceId: defaultWorkspaceId,
|
||||
taskId,
|
||||
providerId: "local",
|
||||
switchTarget: `mock://${taskId}`,
|
||||
};
|
||||
},
|
||||
|
||||
async attachTask(_workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> {
|
||||
return {
|
||||
target: `mock://${taskId}`,
|
||||
sessionId: requireTask(taskId).tabs[0]?.sessionId ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
async runAction(_workspaceId: string, _taskId: string): Promise<void> {
|
||||
notSupported("runAction");
|
||||
},
|
||||
|
||||
async createSandboxSession(): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
notSupported("createSandboxSession");
|
||||
},
|
||||
|
||||
async listSandboxSessions(): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> {
|
||||
return { items: [] };
|
||||
},
|
||||
|
||||
async listSandboxSessionEvents(): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> {
|
||||
return { items: [] };
|
||||
},
|
||||
|
||||
async createSandboxProcess(input: {
|
||||
workspaceId: string;
|
||||
providerId: ProviderId;
|
||||
sandboxId: string;
|
||||
request: ProcessCreateRequest;
|
||||
}): Promise<SandboxProcessRecord> {
|
||||
const task = requireTask(input.sandboxId);
|
||||
const processes = ensureProcessList(input.sandboxId);
|
||||
const created = createProcessRecord(input.sandboxId, mockCwd(task.repoName, task.id), input.request);
|
||||
processes.unshift(created);
|
||||
notifySandbox(input.sandboxId);
|
||||
return cloneProcess(created);
|
||||
},
|
||||
|
||||
async listSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string): Promise<{ processes: SandboxProcessRecord[] }> {
|
||||
return {
|
||||
processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)),
|
||||
};
|
||||
},
|
||||
|
||||
async getSandboxProcessLogs(
|
||||
_workspaceId: string,
|
||||
_providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
query?: ProcessLogFollowQuery,
|
||||
): Promise<ProcessLogsResponse> {
|
||||
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
|
||||
if (!process) {
|
||||
throw new Error(`Unknown mock process ${processId}`);
|
||||
}
|
||||
return {
|
||||
processId,
|
||||
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
|
||||
entries: process.logText
|
||||
? [
|
||||
{
|
||||
data: encodeBase64Utf8(process.logText),
|
||||
encoding: "base64",
|
||||
sequence: 1,
|
||||
stream: query?.stream ?? (process.tty ? "pty" : "combined"),
|
||||
timestampMs: process.createdAtMs,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
},
|
||||
|
||||
async stopSandboxProcess(
|
||||
_workspaceId: string,
|
||||
_providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
_query?: ProcessSignalQuery,
|
||||
): Promise<SandboxProcessRecord> {
|
||||
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
|
||||
if (!process) {
|
||||
throw new Error(`Unknown mock process ${processId}`);
|
||||
}
|
||||
process.status = "exited";
|
||||
process.exitCode = 0;
|
||||
process.exitedAtMs = nowMs();
|
||||
process.logText += "\n[stopped]\n";
|
||||
notifySandbox(sandboxId);
|
||||
return cloneProcess(process);
|
||||
},
|
||||
|
||||
async killSandboxProcess(
|
||||
_workspaceId: string,
|
||||
_providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
processId: string,
|
||||
_query?: ProcessSignalQuery,
|
||||
): Promise<SandboxProcessRecord> {
|
||||
const process = ensureProcessList(sandboxId).find((candidate) => candidate.id === processId);
|
||||
if (!process) {
|
||||
throw new Error(`Unknown mock process ${processId}`);
|
||||
}
|
||||
process.status = "exited";
|
||||
process.exitCode = 137;
|
||||
process.exitedAtMs = nowMs();
|
||||
process.logText += "\n[killed]\n";
|
||||
notifySandbox(sandboxId);
|
||||
return cloneProcess(process);
|
||||
},
|
||||
|
||||
async deleteSandboxProcess(_workspaceId: string, _providerId: ProviderId, sandboxId: string, processId: string): Promise<void> {
|
||||
processesBySandboxId.set(
|
||||
sandboxId,
|
||||
ensureProcessList(sandboxId).filter((candidate) => candidate.id !== processId),
|
||||
);
|
||||
notifySandbox(sandboxId);
|
||||
},
|
||||
|
||||
subscribeSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string, listener: () => void): () => void {
|
||||
let listeners = listenersBySandboxId.get(sandboxId);
|
||||
if (!listeners) {
|
||||
listeners = new Set();
|
||||
listenersBySandboxId.set(sandboxId, listeners);
|
||||
}
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
const current = listenersBySandboxId.get(sandboxId);
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
current.delete(listener);
|
||||
if (current.size === 0) {
|
||||
listenersBySandboxId.delete(sandboxId);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
async sendSandboxPrompt(): Promise<void> {
|
||||
notSupported("sendSandboxPrompt");
|
||||
},
|
||||
|
||||
async sandboxSessionStatus(sessionId: string): Promise<{ id: string; status: "running" | "idle" | "error" }> {
|
||||
return { id: sessionId, status: "idle" };
|
||||
},
|
||||
|
||||
async sandboxProviderState(
|
||||
_workspaceId: string,
|
||||
_providerId: ProviderId,
|
||||
sandboxId: string,
|
||||
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {
|
||||
return { providerId: "local", sandboxId, state: "running", at: nowMs() };
|
||||
},
|
||||
|
||||
async getSandboxAgentConnection(): Promise<{ endpoint: string; token?: string }> {
|
||||
return { endpoint: "mock://terminal-unavailable" };
|
||||
},
|
||||
|
||||
async getWorkbench(): Promise<TaskWorkbenchSnapshot> {
|
||||
return workbench.getSnapshot();
|
||||
},
|
||||
|
||||
subscribeWorkbench(_workspaceId: string, listener: () => void): () => void {
|
||||
return workbench.subscribe(listener);
|
||||
},
|
||||
|
||||
async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
|
||||
return await workbench.createTask(input);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.markTaskUnread(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await workbench.renameTask(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await workbench.renameBranch(input);
|
||||
},
|
||||
|
||||
async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
|
||||
return await workbench.addTab(input);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
await workbench.renameSession(input);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await workbench.setSessionUnread(input);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await workbench.updateDraft(input);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
await workbench.changeModel(input);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
await workbench.sendMessage(input);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await workbench.stopAgent(input);
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await workbench.closeTab(input);
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await workbench.publishPr(input);
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
|
||||
await workbench.revertFile(input);
|
||||
},
|
||||
|
||||
async health(): Promise<{ ok: true }> {
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> {
|
||||
return { workspaceId };
|
||||
},
|
||||
|
||||
async starSandboxAgentRepo(): Promise<StarSandboxAgentRepoResult> {
|
||||
return {
|
||||
repo: "rivet-dev/sandbox-agent",
|
||||
starredAt: nowMs(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
12
foundry/packages/client/src/mock/latency.ts
Normal file
12
foundry/packages/client/src/mock/latency.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
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());
|
||||
});
|
||||
}
|
||||
443
foundry/packages/client/src/mock/workbench-client.ts
Normal file
443
foundry/packages/client/src/mock/workbench-client.ts
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
import {
|
||||
MODEL_GROUPS,
|
||||
buildInitialMockLayoutViewModel,
|
||||
groupWorkbenchProjects,
|
||||
nowMs,
|
||||
providerAgent,
|
||||
randomReply,
|
||||
removeFileTreePath,
|
||||
slugify,
|
||||
uid,
|
||||
} from "../workbench-model.js";
|
||||
import type {
|
||||
TaskWorkbenchAddTabResponse,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchCreateTaskResponse,
|
||||
TaskWorkbenchDiffInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
WorkbenchAgentTab as AgentTab,
|
||||
WorkbenchTask as Task,
|
||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { TaskWorkbenchClient } from "../workbench-client.js";
|
||||
|
||||
function buildTranscriptEvent(params: {
|
||||
sessionId: string;
|
||||
sender: "client" | "agent";
|
||||
createdAt: number;
|
||||
payload: unknown;
|
||||
eventIndex: number;
|
||||
}): TranscriptEvent {
|
||||
return {
|
||||
id: uid(),
|
||||
sessionId: params.sessionId,
|
||||
sender: params.sender,
|
||||
createdAt: params.createdAt,
|
||||
payload: params.payload,
|
||||
connectionId: "mock-connection",
|
||||
eventIndex: params.eventIndex,
|
||||
};
|
||||
}
|
||||
|
||||
class MockWorkbenchStore implements TaskWorkbenchClient {
|
||||
private snapshot = buildInitialMockLayoutViewModel();
|
||||
private listeners = new Set<() => void>();
|
||||
private pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
getSnapshot(): TaskWorkbenchSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
|
||||
const id = uid();
|
||||
const tabId = `session-${id}`;
|
||||
const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId);
|
||||
if (!repo) {
|
||||
throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`);
|
||||
}
|
||||
const nextTask: Task = {
|
||||
id,
|
||||
repoId: repo.id,
|
||||
title: input.title?.trim() || "New Task",
|
||||
status: "new",
|
||||
repoName: repo.label,
|
||||
updatedAtMs: nowMs(),
|
||||
branch: input.branch?.trim() || null,
|
||||
pullRequest: null,
|
||||
tabs: [
|
||||
{
|
||||
id: tabId,
|
||||
sessionId: tabId,
|
||||
sessionName: "Session 1",
|
||||
agent: providerAgent(
|
||||
MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude",
|
||||
),
|
||||
model: input.model ?? "claude-sonnet-4",
|
||||
status: "idle",
|
||||
thinkingSinceMs: null,
|
||||
unread: false,
|
||||
created: false,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: [],
|
||||
},
|
||||
],
|
||||
fileChanges: [],
|
||||
diffs: {},
|
||||
fileTree: [],
|
||||
};
|
||||
|
||||
this.updateState((current) => ({
|
||||
...current,
|
||||
tasks: [nextTask, ...current.tasks],
|
||||
}));
|
||||
return { taskId: id, tabId };
|
||||
}
|
||||
|
||||
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
this.updateTask(input.taskId, (task) => {
|
||||
const targetTab = task.tabs[task.tabs.length - 1] ?? null;
|
||||
if (!targetTab) {
|
||||
return task;
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename task ${input.taskId} to an empty title`);
|
||||
}
|
||||
this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() }));
|
||||
}
|
||||
|
||||
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const value = input.value.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`);
|
||||
}
|
||||
this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() }));
|
||||
}
|
||||
|
||||
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() }));
|
||||
}
|
||||
|
||||
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1;
|
||||
this.updateTask(input.taskId, (task) => ({
|
||||
...task,
|
||||
updatedAtMs: nowMs(),
|
||||
pullRequest: { number: nextPrNumber, status: "ready" },
|
||||
}));
|
||||
}
|
||||
|
||||
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
|
||||
this.updateTask(input.taskId, (task) => {
|
||||
const file = task.fileChanges.find((entry) => entry.path === input.path);
|
||||
const nextDiffs = { ...task.diffs };
|
||||
delete nextDiffs[input.path];
|
||||
|
||||
return {
|
||||
...task,
|
||||
fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path),
|
||||
diffs: nextDiffs,
|
||||
fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
this.assertTab(input.taskId, input.tabId);
|
||||
this.updateTask(input.taskId, (task) => ({
|
||||
...task,
|
||||
updatedAtMs: nowMs(),
|
||||
tabs: task.tabs.map((tab) =>
|
||||
tab.id === input.tabId
|
||||
? {
|
||||
...tab,
|
||||
draft: {
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
updatedAtMs: nowMs(),
|
||||
},
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
const text = input.text.trim();
|
||||
if (!text) {
|
||||
throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`);
|
||||
}
|
||||
|
||||
this.assertTab(input.taskId, input.tabId);
|
||||
const startedAtMs = nowMs();
|
||||
|
||||
this.updateTask(input.taskId, (currentTask) => {
|
||||
const isFirstOnTask = currentTask.status === "new";
|
||||
const newTitle = isFirstOnTask ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentTask.title;
|
||||
const newBranch = isFirstOnTask ? `feat/${slugify(newTitle)}` : currentTask.branch;
|
||||
const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)];
|
||||
const userEvent = buildTranscriptEvent({
|
||||
sessionId: input.tabId,
|
||||
sender: "client",
|
||||
createdAt: startedAtMs,
|
||||
eventIndex: candidateEventIndex(currentTask, input.tabId),
|
||||
payload: {
|
||||
method: "session/prompt",
|
||||
params: {
|
||||
prompt: userMessageLines.map((line) => ({ type: "text", text: line })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...currentTask,
|
||||
title: newTitle,
|
||||
branch: newBranch,
|
||||
status: "running",
|
||||
updatedAtMs: startedAtMs,
|
||||
tabs: currentTask.tabs.map((candidate) =>
|
||||
candidate.id === input.tabId
|
||||
? {
|
||||
...candidate,
|
||||
created: true,
|
||||
status: "running",
|
||||
unread: false,
|
||||
thinkingSinceMs: startedAtMs,
|
||||
draft: { text: "", attachments: [], updatedAtMs: startedAtMs },
|
||||
transcript: [...candidate.transcript, userEvent],
|
||||
}
|
||||
: candidate,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const existingTimer = this.pendingTimers.get(input.tabId);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
const task = this.requireTask(input.taskId);
|
||||
const replyTab = this.requireTab(task, input.tabId);
|
||||
const completedAtMs = nowMs();
|
||||
const replyEvent = buildTranscriptEvent({
|
||||
sessionId: input.tabId,
|
||||
sender: "agent",
|
||||
createdAt: completedAtMs,
|
||||
eventIndex: candidateEventIndex(task, input.tabId),
|
||||
payload: {
|
||||
result: {
|
||||
text: randomReply(),
|
||||
durationMs: completedAtMs - startedAtMs,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.updateTask(input.taskId, (currentTask) => {
|
||||
const updatedTabs = currentTask.tabs.map((candidate) => {
|
||||
if (candidate.id !== input.tabId) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
return {
|
||||
...candidate,
|
||||
status: "idle" as const,
|
||||
thinkingSinceMs: null,
|
||||
unread: true,
|
||||
transcript: [...candidate.transcript, replyEvent],
|
||||
};
|
||||
});
|
||||
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
|
||||
|
||||
return {
|
||||
...currentTask,
|
||||
updatedAtMs: completedAtMs,
|
||||
tabs: updatedTabs,
|
||||
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
|
||||
};
|
||||
});
|
||||
|
||||
this.pendingTimers.delete(input.tabId);
|
||||
}, 2_500);
|
||||
|
||||
this.pendingTimers.set(input.tabId, timer);
|
||||
}
|
||||
|
||||
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
|
||||
this.assertTab(input.taskId, input.tabId);
|
||||
const existing = this.pendingTimers.get(input.tabId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.pendingTimers.delete(input.tabId);
|
||||
}
|
||||
|
||||
this.updateTask(input.taskId, (currentTask) => {
|
||||
const updatedTabs = currentTask.tabs.map((candidate) =>
|
||||
candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate,
|
||||
);
|
||||
const anyRunning = updatedTabs.some((candidate) => candidate.status === "running");
|
||||
|
||||
return {
|
||||
...currentTask,
|
||||
updatedAtMs: nowMs(),
|
||||
tabs: updatedTabs,
|
||||
status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
this.updateTask(input.taskId, (currentTask) => ({
|
||||
...currentTask,
|
||||
tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)),
|
||||
}));
|
||||
}
|
||||
|
||||
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
const title = input.title.trim();
|
||||
if (!title) {
|
||||
throw new Error(`Cannot rename session ${input.tabId} to an empty title`);
|
||||
}
|
||||
this.updateTask(input.taskId, (currentTask) => ({
|
||||
...currentTask,
|
||||
tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)),
|
||||
}));
|
||||
}
|
||||
|
||||
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
|
||||
this.updateTask(input.taskId, (currentTask) => {
|
||||
if (currentTask.tabs.length <= 1) {
|
||||
return currentTask;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentTask,
|
||||
tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
|
||||
this.assertTask(input.taskId);
|
||||
const nextTab: AgentTab = {
|
||||
id: uid(),
|
||||
sessionId: null,
|
||||
sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`,
|
||||
agent: "Claude",
|
||||
model: "claude-sonnet-4",
|
||||
status: "idle",
|
||||
thinkingSinceMs: null,
|
||||
unread: false,
|
||||
created: false,
|
||||
draft: { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: [],
|
||||
};
|
||||
|
||||
this.updateTask(input.taskId, (currentTask) => ({
|
||||
...currentTask,
|
||||
updatedAtMs: nowMs(),
|
||||
tabs: [...currentTask.tabs, nextTab],
|
||||
}));
|
||||
return { tabId: nextTab.id };
|
||||
}
|
||||
|
||||
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
this.updateTask(input.taskId, (currentTask) => ({
|
||||
...currentTask,
|
||||
tabs: currentTask.tabs.map((candidate) =>
|
||||
candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void {
|
||||
const nextSnapshot = updater(this.snapshot);
|
||||
this.snapshot = {
|
||||
...nextSnapshot,
|
||||
projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks),
|
||||
};
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private updateTask(taskId: string, updater: (task: Task) => Task): void {
|
||||
this.assertTask(taskId);
|
||||
this.updateState((current) => ({
|
||||
...current,
|
||||
tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)),
|
||||
}));
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
private assertTask(taskId: string): void {
|
||||
this.requireTask(taskId);
|
||||
}
|
||||
|
||||
private assertTab(taskId: string, tabId: string): void {
|
||||
const task = this.requireTask(taskId);
|
||||
this.requireTab(task, tabId);
|
||||
}
|
||||
|
||||
private requireTask(taskId: string): Task {
|
||||
const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`Unable to find mock task ${taskId}`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private requireTab(task: Task, tabId: string): AgentTab {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateEventIndex(task: Task, tabId: string): number {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
return (tab?.transcript.length ?? 0) + 1;
|
||||
}
|
||||
|
||||
let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null;
|
||||
|
||||
export function getSharedMockWorkbenchClient(): TaskWorkbenchClient {
|
||||
if (!sharedMockWorkbenchClient) {
|
||||
sharedMockWorkbenchClient = new MockWorkbenchStore();
|
||||
}
|
||||
return sharedMockWorkbenchClient;
|
||||
}
|
||||
152
foundry/packages/client/src/remote/app-client.ts
Normal file
152
foundry/packages/client/src/remote/app-client.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import type { FoundryAppClient } from "../app-client.js";
|
||||
|
||||
export interface RemoteFoundryAppClientOptions {
|
||||
backend: BackendClient;
|
||||
}
|
||||
|
||||
class RemoteFoundryAppStore implements FoundryAppClient {
|
||||
private readonly backend: BackendClient;
|
||||
private snapshot: FoundryAppSnapshot = {
|
||||
auth: { status: "signed_out", currentUserId: null },
|
||||
activeOrganizationId: null,
|
||||
onboarding: {
|
||||
starterRepo: {
|
||||
repoFullName: "rivet-dev/sandbox-agent",
|
||||
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
|
||||
status: "pending",
|
||||
starredAt: null,
|
||||
skippedAt: null,
|
||||
},
|
||||
},
|
||||
users: [],
|
||||
organizations: [],
|
||||
};
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private syncPollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: RemoteFoundryAppClientOptions) {
|
||||
this.backend = options.backend;
|
||||
}
|
||||
|
||||
getSnapshot(): FoundryAppSnapshot {
|
||||
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> {
|
||||
void userId;
|
||||
await this.backend.signInWithGithub();
|
||||
}
|
||||
|
||||
async signOut(): Promise<void> {
|
||||
this.snapshot = await this.backend.signOutApp();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async skipStarterRepo(): Promise<void> {
|
||||
this.snapshot = await this.backend.skipAppStarterRepo();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async starStarterRepo(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.starAppStarterRepo(organizationId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async selectOrganization(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.selectAppOrganization(organizationId);
|
||||
this.notify();
|
||||
this.scheduleSyncPollingIfNeeded();
|
||||
}
|
||||
|
||||
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
|
||||
this.snapshot = await this.backend.updateAppOrganizationProfile(input);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
async triggerGithubSync(organizationId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.triggerAppRepoImport(organizationId);
|
||||
this.notify();
|
||||
this.scheduleSyncPollingIfNeeded();
|
||||
}
|
||||
|
||||
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
|
||||
await this.backend.completeAppHostedCheckout(organizationId, planId);
|
||||
}
|
||||
|
||||
async openBillingPortal(organizationId: string): Promise<void> {
|
||||
await this.backend.openAppBillingPortal(organizationId);
|
||||
}
|
||||
|
||||
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> {
|
||||
await this.backend.reconnectAppGithub(organizationId);
|
||||
}
|
||||
|
||||
async recordSeatUsage(workspaceId: string): Promise<void> {
|
||||
this.snapshot = await this.backend.recordAppSeatUsage(workspaceId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
private scheduleSyncPollingIfNeeded(): void {
|
||||
if (this.syncPollTimeout) {
|
||||
clearTimeout(this.syncPollTimeout);
|
||||
this.syncPollTimeout = null;
|
||||
}
|
||||
|
||||
if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncPollTimeout = setTimeout(() => {
|
||||
this.syncPollTimeout = 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.scheduleSyncPollingIfNeeded();
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private notify(): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteFoundryAppClient(options: RemoteFoundryAppClientOptions): FoundryAppClient {
|
||||
return new RemoteFoundryAppStore(options);
|
||||
}
|
||||
197
foundry/packages/client/src/remote/workbench-client.ts
Normal file
197
foundry/packages/client/src/remote/workbench-client.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import type {
|
||||
TaskWorkbenchAddTabResponse,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchCreateTaskResponse,
|
||||
TaskWorkbenchDiffInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import { groupWorkbenchProjects } from "../workbench-model.js";
|
||||
import type { TaskWorkbenchClient } from "../workbench-client.js";
|
||||
|
||||
export interface RemoteWorkbenchClientOptions {
|
||||
backend: BackendClient;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
class RemoteWorkbenchStore implements TaskWorkbenchClient {
|
||||
private readonly backend: BackendClient;
|
||||
private readonly workspaceId: string;
|
||||
private snapshot: TaskWorkbenchSnapshot;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private unsubscribeWorkbench: (() => void) | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: RemoteWorkbenchClientOptions) {
|
||||
this.backend = options.backend;
|
||||
this.workspaceId = options.workspaceId;
|
||||
this.snapshot = {
|
||||
workspaceId: options.workspaceId,
|
||||
repos: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): TaskWorkbenchSnapshot {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
this.ensureStarted();
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
if (this.listeners.size === 0 && this.refreshRetryTimeout) {
|
||||
clearTimeout(this.refreshRetryTimeout);
|
||||
this.refreshRetryTimeout = null;
|
||||
}
|
||||
if (this.listeners.size === 0 && this.unsubscribeWorkbench) {
|
||||
this.unsubscribeWorkbench();
|
||||
this.unsubscribeWorkbench = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
|
||||
const created = await this.backend.createWorkbenchTask(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
return created;
|
||||
}
|
||||
|
||||
async markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await this.backend.markWorkbenchUnread(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async renameTask(input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await this.backend.renameWorkbenchTask(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async renameBranch(input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
await this.backend.renameWorkbenchBranch(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async archiveTask(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await this.backend.runAction(this.workspaceId, input.taskId, "archive");
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async publishPr(input: TaskWorkbenchSelectInput): Promise<void> {
|
||||
await this.backend.publishWorkbenchPr(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async revertFile(input: TaskWorkbenchDiffInput): Promise<void> {
|
||||
await this.backend.revertWorkbenchFile(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
await this.backend.sendWorkbenchMessage(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async stopAgent(input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await this.backend.stopWorkbenchSession(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
await this.backend.setWorkbenchSessionUnread(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
await this.backend.renameWorkbenchSession(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async closeTab(input: TaskWorkbenchTabInput): Promise<void> {
|
||||
await this.backend.closeWorkbenchSession(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse> {
|
||||
const created = await this.backend.createWorkbenchSession(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
return created;
|
||||
}
|
||||
|
||||
async changeModel(input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
await this.backend.changeWorkbenchModel(this.workspaceId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
private ensureStarted(): void {
|
||||
if (!this.unsubscribeWorkbench) {
|
||||
this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.workspaceId, () => {
|
||||
void this.refresh().catch(() => {
|
||||
this.scheduleRefreshRetry();
|
||||
});
|
||||
});
|
||||
}
|
||||
void this.refresh().catch(() => {
|
||||
this.scheduleRefreshRetry();
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleRefreshRetry(): void {
|
||||
if (this.refreshRetryTimeout || this.listeners.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshRetryTimeout = setTimeout(() => {
|
||||
this.refreshRetryTimeout = null;
|
||||
void this.refresh().catch(() => {
|
||||
this.scheduleRefreshRetry();
|
||||
});
|
||||
}, 1_000);
|
||||
}
|
||||
|
||||
private async refresh(): Promise<void> {
|
||||
if (this.refreshPromise) {
|
||||
await this.refreshPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.refreshPromise = (async () => {
|
||||
const nextSnapshot = await this.backend.getWorkbench(this.workspaceId);
|
||||
if (this.refreshRetryTimeout) {
|
||||
clearTimeout(this.refreshRetryTimeout);
|
||||
this.refreshRetryTimeout = null;
|
||||
}
|
||||
this.snapshot = {
|
||||
...nextSnapshot,
|
||||
projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks),
|
||||
};
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
||||
await this.refreshPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient {
|
||||
return new RemoteWorkbenchStore(options);
|
||||
}
|
||||
104
foundry/packages/client/src/view-model.ts
Normal file
104
foundry/packages/client/src/view-model.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export const TASK_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const;
|
||||
|
||||
export type TaskStatusGroup = (typeof TASK_STATUS_GROUPS)[number];
|
||||
|
||||
const QUEUED_STATUSES = new Set<TaskStatus>([
|
||||
"init_bootstrap_db",
|
||||
"init_enqueue_provision",
|
||||
"init_ensure_name",
|
||||
"init_assert_name",
|
||||
"init_create_sandbox",
|
||||
"init_ensure_agent",
|
||||
"init_start_sandbox_instance",
|
||||
"init_create_session",
|
||||
"init_write_db",
|
||||
"init_start_status_sync",
|
||||
"init_complete",
|
||||
"archive_stop_status_sync",
|
||||
"archive_release_sandbox",
|
||||
"archive_finalize",
|
||||
"kill_destroy_sandbox",
|
||||
"kill_finalize",
|
||||
]);
|
||||
|
||||
export function groupTaskStatus(status: TaskStatus): TaskStatusGroup {
|
||||
if (status === "running") return "running";
|
||||
if (status === "idle") return "idle";
|
||||
if (status === "archived") return "archived";
|
||||
if (status === "killed") return "killed";
|
||||
if (status === "error") return "error";
|
||||
if (QUEUED_STATUSES.has(status)) return "queued";
|
||||
return "queued";
|
||||
}
|
||||
|
||||
function emptyStatusCounts(): Record<TaskStatusGroup, number> {
|
||||
return {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
idle: 0,
|
||||
archived: 0,
|
||||
killed: 0,
|
||||
error: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export interface TaskSummary {
|
||||
total: number;
|
||||
byStatus: Record<TaskStatusGroup, number>;
|
||||
byProvider: Record<string, number>;
|
||||
}
|
||||
|
||||
export function fuzzyMatch(target: string, query: string): boolean {
|
||||
const haystack = target.toLowerCase();
|
||||
const needle = query.toLowerCase();
|
||||
let i = 0;
|
||||
for (const ch of needle) {
|
||||
i = haystack.indexOf(ch, i);
|
||||
if (i < 0) {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] {
|
||||
const q = query.trim();
|
||||
if (!q) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((row) => {
|
||||
const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""];
|
||||
return fields.some((field) => fuzzyMatch(field, q));
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeAge(updatedAt: number, now = Date.now()): string {
|
||||
const deltaSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000));
|
||||
if (deltaSeconds < 60) return `${deltaSeconds}s`;
|
||||
const minutes = Math.floor(deltaSeconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
export function summarizeTasks(rows: TaskRecord[]): TaskSummary {
|
||||
const byStatus = emptyStatusCounts();
|
||||
const byProvider: Record<string, number> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
byStatus[groupTaskStatus(row.status)] += 1;
|
||||
byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
byStatus,
|
||||
byProvider,
|
||||
};
|
||||
}
|
||||
64
foundry/packages/client/src/workbench-client.ts
Normal file
64
foundry/packages/client/src/workbench-client.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import type {
|
||||
TaskWorkbenchAddTabResponse,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchCreateTaskInput,
|
||||
TaskWorkbenchCreateTaskResponse,
|
||||
TaskWorkbenchDiffInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSelectInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchSnapshot,
|
||||
TaskWorkbenchTabInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js";
|
||||
import { createRemoteWorkbenchClient } from "./remote/workbench-client.js";
|
||||
|
||||
export type TaskWorkbenchClientMode = "mock" | "remote";
|
||||
|
||||
export interface CreateTaskWorkbenchClientOptions {
|
||||
mode: TaskWorkbenchClientMode;
|
||||
backend?: BackendClient;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface TaskWorkbenchClient {
|
||||
getSnapshot(): TaskWorkbenchSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
createTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
|
||||
markTaskUnread(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
renameTask(input: TaskWorkbenchRenameInput): Promise<void>;
|
||||
renameBranch(input: TaskWorkbenchRenameInput): Promise<void>;
|
||||
archiveTask(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
publishPr(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||
revertFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||
updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void>;
|
||||
sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void>;
|
||||
stopAgent(input: TaskWorkbenchTabInput): Promise<void>;
|
||||
setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise<void>;
|
||||
renameSession(input: TaskWorkbenchRenameSessionInput): Promise<void>;
|
||||
closeTab(input: TaskWorkbenchTabInput): Promise<void>;
|
||||
addTab(input: TaskWorkbenchSelectInput): Promise<TaskWorkbenchAddTabResponse>;
|
||||
changeModel(input: TaskWorkbenchChangeModelInput): Promise<void>;
|
||||
}
|
||||
|
||||
export function createTaskWorkbenchClient(options: CreateTaskWorkbenchClientOptions): TaskWorkbenchClient {
|
||||
if (options.mode === "mock") {
|
||||
return getSharedMockWorkbenchClient();
|
||||
}
|
||||
|
||||
if (!options.backend) {
|
||||
throw new Error("Remote task workbench client requires a backend client");
|
||||
}
|
||||
if (!options.workspaceId) {
|
||||
throw new Error("Remote task workbench client requires a workspace id");
|
||||
}
|
||||
|
||||
return createRemoteWorkbenchClient({
|
||||
backend: options.backend,
|
||||
workspaceId: options.workspaceId,
|
||||
});
|
||||
}
|
||||
1166
foundry/packages/client/src/workbench-model.ts
Normal file
1166
foundry/packages/client/src/workbench-model.ts
Normal file
File diff suppressed because it is too large
Load diff
174
foundry/packages/client/test/e2e/full-integration-e2e.test.ts
Normal file
174
foundry/packages/client/test/e2e/full-integration-e2e.test.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGithubRepo(input: string): { fullName: string } {
|
||||
const trimmed = input.trim();
|
||||
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (shorthand) {
|
||||
return { fullName: `${shorthand[1]}/${shorthand[2]}` };
|
||||
}
|
||||
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
|
||||
return { fullName: `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}` };
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse GitHub repo from: ${input}`);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise<T>, isDone: (value: T) => boolean): Promise<T> {
|
||||
const start = Date.now();
|
||||
let last: T;
|
||||
for (;;) {
|
||||
last = await fn();
|
||||
if (isDone(last)) {
|
||||
return last;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(event.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
|
||||
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureRemoteBranchExists(token: string, fullName: string, branchName: string): Promise<void> {
|
||||
const repoRes = await githubApi(token, `repos/${fullName}`, { method: "GET" });
|
||||
if (!repoRes.ok) {
|
||||
throw new Error(`GitHub repo lookup failed: ${repoRes.status} ${await repoRes.text()}`);
|
||||
}
|
||||
const repo = (await repoRes.json()) as { default_branch?: string };
|
||||
const defaultBranch = repo.default_branch;
|
||||
if (!defaultBranch) {
|
||||
throw new Error(`GitHub repo default branch is missing for ${fullName}`);
|
||||
}
|
||||
|
||||
const defaultRefRes = await githubApi(token, `repos/${fullName}/git/ref/heads/${encodeURIComponent(defaultBranch)}`, { method: "GET" });
|
||||
if (!defaultRefRes.ok) {
|
||||
throw new Error(`GitHub default ref lookup failed: ${defaultRefRes.status} ${await defaultRefRes.text()}`);
|
||||
}
|
||||
const defaultRef = (await defaultRefRes.json()) as { object?: { sha?: string } };
|
||||
const sha = defaultRef.object?.sha;
|
||||
if (!sha) {
|
||||
throw new Error(`GitHub default ref sha missing for ${fullName}:${defaultBranch}`);
|
||||
}
|
||||
|
||||
const createRefRes = await githubApi(token, `repos/${fullName}/git/refs`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha,
|
||||
}),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (createRefRes.ok || createRefRes.status === 422) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`GitHub create ref failed: ${createRefRes.status} ${await createRefRes.text()}`);
|
||||
}
|
||||
|
||||
describe("e2e(client): full integration stack workflow", () => {
|
||||
it.skipIf(!RUN_FULL_E2E)("adds repo, loads branch graph, and executes a stack restack action", { timeout: 8 * 60_000 }, 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 githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
const { fullName } = parseGithubRepo(repoRemote);
|
||||
const normalizedRepoRemote = `https://github.com/${fullName}.git`;
|
||||
const seededBranch = `e2e/full-seed-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
try {
|
||||
await ensureRemoteBranchExists(githubToken, fullName, seededBranch);
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
expect(repo.remoteUrl).toBe(normalizedRepoRemote);
|
||||
|
||||
const overview = await poll<RepoOverview>(
|
||||
"repo overview includes seeded branch",
|
||||
90_000,
|
||||
1_000,
|
||||
async () => client.getRepoOverview(workspaceId, repo.repoId),
|
||||
(value) => value.branches.some((row) => row.branchName === seededBranch),
|
||||
);
|
||||
|
||||
if (!overview.stackAvailable) {
|
||||
throw new Error(
|
||||
"git-spice is unavailable for this repo during full integration e2e; set HF_GIT_SPICE_BIN or install git-spice in the backend container",
|
||||
);
|
||||
}
|
||||
|
||||
const stackResult = await client.runRepoStackAction({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
action: "restack_repo",
|
||||
});
|
||||
expect(stackResult.executed).toBe(true);
|
||||
expect(stackResult.action).toBe("restack_repo");
|
||||
|
||||
await poll<HistoryEvent[]>(
|
||||
"repo stack action history event",
|
||||
60_000,
|
||||
1_000,
|
||||
async () => client.listHistory({ workspaceId, limit: 200 }),
|
||||
(events) =>
|
||||
events.some((event) => {
|
||||
if (event.kind !== "repo.stack_action") {
|
||||
return false;
|
||||
}
|
||||
const payload = parseHistoryPayload(event);
|
||||
return payload.action === "restack_repo";
|
||||
}),
|
||||
);
|
||||
|
||||
const postActionOverview = await client.getRepoOverview(workspaceId, repo.repoId);
|
||||
const seededRow = postActionOverview.branches.find((row) => row.branchName === seededBranch);
|
||||
expect(Boolean(seededRow)).toBe(true);
|
||||
expect(postActionOverview.fetchedAt).toBeGreaterThan(overview.fetchedAt);
|
||||
} finally {
|
||||
await githubApi(githubToken, `repos/${fullName}/git/refs/heads/${encodeURIComponent(seededBranch)}`, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
332
foundry/packages/client/test/e2e/github-pr-e2e.test.ts
Normal file
332
foundry/packages/client/test/e2e/github-pr-e2e.test.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseGithubRepo(input: string): { owner: string; repo: string; fullName: string } {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("HF_E2E_GITHUB_REPO is empty");
|
||||
}
|
||||
|
||||
// owner/repo shorthand
|
||||
const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/);
|
||||
if (shorthand) {
|
||||
const owner = shorthand[1]!;
|
||||
const repo = shorthand[2]!;
|
||||
return { owner, repo, fullName: `${owner}/${repo}` };
|
||||
}
|
||||
|
||||
// https://github.com/owner/repo(.git)?(/...)?
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`);
|
||||
const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean);
|
||||
if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) {
|
||||
const owner = parts[0]!;
|
||||
const repo = (parts[1] ?? "").replace(/\.git$/, "");
|
||||
if (owner && repo) {
|
||||
return { owner, repo, fullName: `${owner}/${repo}` };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
throw new Error(`Unable to parse GitHub repo from: ${input}`);
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(
|
||||
label: string,
|
||||
timeoutMs: number,
|
||||
intervalMs: number,
|
||||
fn: () => Promise<T>,
|
||||
isDone: (value: T) => boolean,
|
||||
onTick?: (value: T) => void,
|
||||
): Promise<T> {
|
||||
const start = Date.now();
|
||||
let last: T;
|
||||
for (;;) {
|
||||
last = await fn();
|
||||
onTick?.(last);
|
||||
if (isDone(last)) {
|
||||
return last;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
|
||||
try {
|
||||
return JSON.parse(event.payloadJson) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, taskId: string): Promise<string> {
|
||||
try {
|
||||
const task = await client.getTask(workspaceId, taskId);
|
||||
const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []);
|
||||
const historySummary = history
|
||||
.slice(0, 20)
|
||||
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
|
||||
.join("\n");
|
||||
|
||||
let sessionEventsSummary = "";
|
||||
if (task.activeSandboxId && task.activeSessionId) {
|
||||
const events = await client
|
||||
.listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, {
|
||||
sessionId: task.activeSessionId,
|
||||
limit: 50,
|
||||
})
|
||||
.then((r) => r.items)
|
||||
.catch(() => []);
|
||||
sessionEventsSummary = events
|
||||
.slice(-12)
|
||||
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.sender}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
return [
|
||||
"=== task ===",
|
||||
JSON.stringify(
|
||||
{
|
||||
status: task.status,
|
||||
statusMessage: task.statusMessage,
|
||||
title: task.title,
|
||||
branchName: task.branchName,
|
||||
activeSandboxId: task.activeSandboxId,
|
||||
activeSessionId: task.activeSessionId,
|
||||
prUrl: task.prUrl,
|
||||
prSubmitted: task.prSubmitted,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"=== history (most recent first) ===",
|
||||
historySummary || "(none)",
|
||||
"=== session events (tail) ===",
|
||||
sessionEventsSummary || "(none)",
|
||||
].join("\n");
|
||||
} catch (err) {
|
||||
return `debug dump failed: ${err instanceof Error ? err.message : String(err)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function githubApi(token: string, path: string, init?: RequestInit): Promise<Response> {
|
||||
const url = `https://api.github.com/${path.replace(/^\/+/, "")}`;
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
|
||||
it.skipIf(!RUN_E2E)("creates a task, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, 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 githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
|
||||
const { fullName } = parseGithubRepo(repoRemote);
|
||||
const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const expectedFile = `e2e/${runId}.txt`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
|
||||
const created = await client.createTask({
|
||||
workspaceId,
|
||||
repoId: repo.repoId,
|
||||
task: [
|
||||
"E2E test task:",
|
||||
`1. Create a new file at ${expectedFile} containing the single line: ${runId}`,
|
||||
"2. git add the file",
|
||||
`3. git commit -m \"test(e2e): ${runId}\"`,
|
||||
"4. git push the branch to origin",
|
||||
"5. Stop when done (agent should go idle).",
|
||||
].join("\n"),
|
||||
providerId: "daytona",
|
||||
explicitTitle: `test(e2e): ${runId}`,
|
||||
explicitBranchName: `e2e/${runId}`,
|
||||
});
|
||||
|
||||
let prNumber: number | null = null;
|
||||
let branchName: string | null = null;
|
||||
let sandboxId: string | null = null;
|
||||
let sessionId: string | null = null;
|
||||
let lastStatus: string | null = null;
|
||||
|
||||
try {
|
||||
const namedAndProvisioned = await poll<TaskRecord>(
|
||||
"task naming + sandbox provisioning",
|
||||
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
|
||||
8 * 60_000,
|
||||
1_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
|
||||
(h) => {
|
||||
if (h.status !== lastStatus) {
|
||||
lastStatus = h.status;
|
||||
}
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state during provisioning");
|
||||
}
|
||||
},
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
branchName = namedAndProvisioned.branchName!;
|
||||
sandboxId = namedAndProvisioned.activeSandboxId!;
|
||||
|
||||
const withSession = await poll<TaskRecord>(
|
||||
"task to create active session",
|
||||
3 * 60_000,
|
||||
1_500,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => Boolean(h.activeSessionId),
|
||||
(h) => {
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state while waiting for active session");
|
||||
}
|
||||
},
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
sessionId = withSession.activeSessionId!;
|
||||
|
||||
await poll<{ id: string }[]>(
|
||||
"session transcript bootstrap events",
|
||||
2 * 60_000,
|
||||
2_000,
|
||||
async () =>
|
||||
(
|
||||
await client.listSandboxSessionEvents(workspaceId, withSession.providerId, sandboxId!, {
|
||||
sessionId: sessionId!,
|
||||
limit: 40,
|
||||
})
|
||||
).items,
|
||||
(events) => events.length > 0,
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
await poll<TaskRecord>(
|
||||
"task to reach idle state",
|
||||
8 * 60_000,
|
||||
2_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => h.status === "idle",
|
||||
(h) => {
|
||||
if (h.status === "error") {
|
||||
throw new Error("task entered error state while waiting for idle");
|
||||
}
|
||||
},
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
const prCreatedEvent = await poll<HistoryEvent[]>(
|
||||
"PR creation history event",
|
||||
3 * 60_000,
|
||||
2_000,
|
||||
async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }),
|
||||
(events) => events.some((e) => e.kind === "task.pr_created"),
|
||||
)
|
||||
.catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
})
|
||||
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
|
||||
|
||||
const payload = parseHistoryPayload(prCreatedEvent);
|
||||
prNumber = Number(payload.prNumber);
|
||||
const prUrl = String(payload.prUrl ?? "");
|
||||
|
||||
expect(prNumber).toBeGreaterThan(0);
|
||||
expect(prUrl).toContain("/pull/");
|
||||
|
||||
const prFilesRes = await githubApi(githubToken, `repos/${fullName}/pulls/${prNumber}/files?per_page=100`, { method: "GET" });
|
||||
if (!prFilesRes.ok) {
|
||||
const body = await prFilesRes.text();
|
||||
throw new Error(`GitHub PR files request failed: ${prFilesRes.status} ${body}`);
|
||||
}
|
||||
const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>;
|
||||
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
|
||||
|
||||
// Close the task and assert the sandbox is released (stopped).
|
||||
await client.runAction(workspaceId, created.taskId, "archive");
|
||||
|
||||
await poll<TaskRecord>(
|
||||
"task to become archived (session released)",
|
||||
60_000,
|
||||
1_000,
|
||||
async () => client.getTask(workspaceId, created.taskId),
|
||||
(h) => h.status === "archived" && h.activeSessionId === null,
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
|
||||
});
|
||||
|
||||
if (sandboxId) {
|
||||
await poll<{ providerId: string; sandboxId: string; state: string; at: number }>(
|
||||
"daytona sandbox to stop",
|
||||
2 * 60_000,
|
||||
2_000,
|
||||
async () => client.sandboxProviderState(workspaceId, "daytona", sandboxId!),
|
||||
(s) => {
|
||||
const st = String(s.state).toLowerCase();
|
||||
return st.includes("stopped") || st.includes("suspended") || st.includes("paused");
|
||||
},
|
||||
).catch(async (err) => {
|
||||
const dump = await debugDump(client, workspaceId, created.taskId);
|
||||
const state = await client.sandboxProviderState(workspaceId, "daytona", sandboxId!).catch(() => null);
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n` + `sandbox state: ${state ? state.state : "unknown"}\n` + `${dump}`);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (prNumber && Number.isFinite(prNumber)) {
|
||||
await githubApi(githubToken, `repos/${fullName}/pulls/${prNumber}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (branchName) {
|
||||
await githubApi(githubToken, `repos/${fullName}/git/refs/heads/${encodeURIComponent(branchName)}`, { method: "DELETE" }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
307
foundry/packages/client/test/e2e/workbench-e2e.test.ts
Normal file
307
foundry/packages/client/test/e2e/workbench-e2e.test.ts
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1";
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
case "claude-sonnet-4":
|
||||
case "claude-opus-4":
|
||||
case "gpt-4o":
|
||||
case "o3":
|
||||
return value;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function seedSandboxFile(workspaceId: string, taskId: string, filePath: string, content: string): Promise<void> {
|
||||
const repoPath = `/root/.local/share/foundry/local-sandboxes/${workspaceId}/${taskId}/repo`;
|
||||
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", "foundry-backend-1", "bash", "-lc", script]);
|
||||
}
|
||||
|
||||
async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise<T>, isDone: (value: T) => boolean): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
let lastValue: T;
|
||||
|
||||
for (;;) {
|
||||
lastValue = await fn();
|
||||
if (isDone(lastValue)) {
|
||||
return lastValue;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`tab ${tabId} missing from task ${task.id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function extractEventText(event: WorkbenchTranscriptEvent): string {
|
||||
const payload = event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return String(payload ?? "");
|
||||
}
|
||||
|
||||
const envelope = payload as {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const params = envelope.params;
|
||||
if (params && typeof params === "object") {
|
||||
const update = (params as { update?: unknown }).update;
|
||||
if (update && typeof update === "object") {
|
||||
const content = (update as { content?: unknown }).content;
|
||||
if (content && typeof content === "object") {
|
||||
const chunkText = (content as { text?: unknown }).text;
|
||||
if (typeof chunkText === "string") {
|
||||
return chunkText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
|
||||
if (Array.isArray(prompt)) {
|
||||
const value = prompt
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = envelope.result;
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (envelope.error) {
|
||||
return JSON.stringify(envelope.error);
|
||||
}
|
||||
|
||||
if (typeof envelope.method === "string") {
|
||||
return envelope.method;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
|
||||
return transcript
|
||||
.filter((event) => event.sender === "agent")
|
||||
.map((event) => extractEventText(event))
|
||||
.join("")
|
||||
.includes(expectedText);
|
||||
}
|
||||
|
||||
describe("e2e(client): workbench flows", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_E2E)("creates a task, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, 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 model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const runId = `wb-${Date.now().toString(36)}`;
|
||||
const expectedFile = `${runId}.txt`;
|
||||
const expectedInitialReply = `WORKBENCH_READY_${runId}`;
|
||||
const expectedReply = `WORKBENCH_ACK_${runId}`;
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
const created = await client.createWorkbenchTask(workspaceId, {
|
||||
repoId: repo.repoId,
|
||||
title: `Workbench E2E ${runId}`,
|
||||
branch: `e2e/${runId}`,
|
||||
model,
|
||||
task: `Reply with exactly: ${expectedInitialReply}`,
|
||||
});
|
||||
|
||||
const provisioned = await poll(
|
||||
"task provisioning",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => task.branch === `e2e/${runId}` && task.tabs.length > 0,
|
||||
);
|
||||
|
||||
const primaryTab = provisioned.tabs[0]!;
|
||||
|
||||
const initialCompleted = await poll(
|
||||
"initial agent response",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, primaryTab.id);
|
||||
return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply);
|
||||
},
|
||||
);
|
||||
|
||||
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
|
||||
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
|
||||
|
||||
await seedSandboxFile(workspaceId, created.taskId, expectedFile, runId);
|
||||
|
||||
const fileSeeded = await poll(
|
||||
"seeded sandbox file reflected in workbench",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => task.fileChanges.some((file) => file.path === expectedFile),
|
||||
);
|
||||
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
|
||||
|
||||
await client.renameWorkbenchTask(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
value: `Workbench E2E ${runId} Renamed`,
|
||||
});
|
||||
await client.renameWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: primaryTab.id,
|
||||
title: "Primary Session",
|
||||
});
|
||||
|
||||
const secondTab = await client.createWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
model,
|
||||
});
|
||||
|
||||
await client.renameWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
title: "Follow-up Session",
|
||||
});
|
||||
|
||||
await client.updateWorkbenchDraft(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
text: `Reply with exactly: ${expectedReply}`,
|
||||
attachments: [
|
||||
{
|
||||
id: `${expectedFile}:1`,
|
||||
filePath: expectedFile,
|
||||
lineNumber: 1,
|
||||
lineContent: runId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId);
|
||||
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
|
||||
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
|
||||
|
||||
await client.sendWorkbenchMessage(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
text: `Reply with exactly: ${expectedReply}`,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const withSecondReply = await poll(
|
||||
"follow-up session response",
|
||||
10 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, secondTab.tabId);
|
||||
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
|
||||
},
|
||||
);
|
||||
|
||||
const secondTranscript = findTab(withSecondReply, secondTab.tabId).transcript;
|
||||
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
|
||||
|
||||
await client.setWorkbenchSessionUnread(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
unread: false,
|
||||
});
|
||||
await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId });
|
||||
|
||||
const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId);
|
||||
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
|
||||
|
||||
await client.closeWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: secondTab.tabId,
|
||||
});
|
||||
|
||||
const closedSnapshot = await poll(
|
||||
"secondary session closed",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => !task.tabs.some((tab) => tab.id === secondTab.tabId),
|
||||
);
|
||||
expect(closedSnapshot.tabs).toHaveLength(1);
|
||||
|
||||
await client.revertWorkbenchFile(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
path: expectedFile,
|
||||
});
|
||||
|
||||
const revertedSnapshot = await poll(
|
||||
"file revert reflected in workbench",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
|
||||
);
|
||||
|
||||
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);
|
||||
expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`);
|
||||
expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session");
|
||||
});
|
||||
});
|
||||
306
foundry/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal file
306
foundry/packages/client/test/e2e/workbench-load-e2e.test.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Missing required env var: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId {
|
||||
const value = process.env[name]?.trim();
|
||||
switch (value) {
|
||||
case "claude-sonnet-4":
|
||||
case "claude-opus-4":
|
||||
case "gpt-4o":
|
||||
case "o3":
|
||||
return value;
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function intEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise<T>, isDone: (value: T) => boolean): Promise<T> {
|
||||
const startedAt = Date.now();
|
||||
let lastValue: T;
|
||||
|
||||
for (;;) {
|
||||
lastValue = await fn();
|
||||
if (isDone(lastValue)) {
|
||||
return lastValue;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
|
||||
const tab = task.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (!tab) {
|
||||
throw new Error(`tab ${tabId} missing from task ${task.id}`);
|
||||
}
|
||||
return tab;
|
||||
}
|
||||
|
||||
function extractEventText(event: WorkbenchTranscriptEvent): string {
|
||||
const payload = event.payload;
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return String(payload ?? "");
|
||||
}
|
||||
|
||||
const envelope = payload as {
|
||||
method?: unknown;
|
||||
params?: unknown;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
const params = envelope.params;
|
||||
if (params && typeof params === "object") {
|
||||
const update = (params as { update?: unknown }).update;
|
||||
if (update && typeof update === "object") {
|
||||
const content = (update as { content?: unknown }).content;
|
||||
if (content && typeof content === "object") {
|
||||
const chunkText = (content as { text?: unknown }).text;
|
||||
if (typeof chunkText === "string") {
|
||||
return chunkText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const text = (params as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt;
|
||||
if (Array.isArray(prompt)) {
|
||||
return prompt
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
const result = envelope.result;
|
||||
if (result && typeof result === "object") {
|
||||
const text = (result as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean {
|
||||
return transcript
|
||||
.filter((event) => event.sender === "agent")
|
||||
.map((event) => extractEventText(event))
|
||||
.join("")
|
||||
.includes(expectedText);
|
||||
}
|
||||
|
||||
function average(values: number[]): number {
|
||||
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
||||
}
|
||||
|
||||
async function measureWorkbenchSnapshot(
|
||||
client: ReturnType<typeof createBackendClient>,
|
||||
workspaceId: string,
|
||||
iterations: number,
|
||||
): Promise<{
|
||||
avgMs: number;
|
||||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
taskCount: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> {
|
||||
const durations: number[] = [];
|
||||
let snapshot: TaskWorkbenchSnapshot | null = null;
|
||||
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const startedAt = performance.now();
|
||||
snapshot = await client.getWorkbench(workspaceId);
|
||||
durations.push(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
const finalSnapshot = snapshot ?? {
|
||||
workspaceId,
|
||||
repos: [],
|
||||
projects: [],
|
||||
tasks: [],
|
||||
};
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
|
||||
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0);
|
||||
const transcriptEventCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), 0);
|
||||
|
||||
return {
|
||||
avgMs: Math.round(average(durations)),
|
||||
maxMs: Math.round(Math.max(...durations, 0)),
|
||||
payloadBytes,
|
||||
taskCount: finalSnapshot.tasks.length,
|
||||
tabCount,
|
||||
transcriptEventCount,
|
||||
};
|
||||
}
|
||||
|
||||
describe("e2e(client): workbench load", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, 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 model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
const taskCount = intEnv("HF_LOAD_TASK_COUNT", 3);
|
||||
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
|
||||
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
|
||||
|
||||
const client = createBackendClient({
|
||||
endpoint,
|
||||
defaultWorkspaceId: workspaceId,
|
||||
});
|
||||
|
||||
const repo = await client.addRepo(workspaceId, repoRemote);
|
||||
const createTaskLatencies: number[] = [];
|
||||
const provisionLatencies: number[] = [];
|
||||
const createSessionLatencies: number[] = [];
|
||||
const messageRoundTripLatencies: number[] = [];
|
||||
const snapshotSeries: Array<{
|
||||
taskCount: number;
|
||||
avgMs: number;
|
||||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> = [];
|
||||
|
||||
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
|
||||
|
||||
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
|
||||
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
|
||||
const initialReply = `LOAD_INIT_${runId}`;
|
||||
|
||||
const createStartedAt = performance.now();
|
||||
const created = await client.createWorkbenchTask(workspaceId, {
|
||||
repoId: repo.repoId,
|
||||
title: `Workbench Load ${runId}`,
|
||||
branch: `load/${runId}`,
|
||||
model,
|
||||
task: `Reply with exactly: ${initialReply}`,
|
||||
});
|
||||
createTaskLatencies.push(performance.now() - createStartedAt);
|
||||
|
||||
const provisionStartedAt = performance.now();
|
||||
const provisioned = await poll(
|
||||
`task ${runId} provisioning`,
|
||||
12 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = task.tabs[0];
|
||||
return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply));
|
||||
},
|
||||
);
|
||||
provisionLatencies.push(performance.now() - provisionStartedAt);
|
||||
|
||||
expect(provisioned.tabs.length).toBeGreaterThan(0);
|
||||
const primaryTab = provisioned.tabs[0]!;
|
||||
expect(transcriptIncludesAgentText(primaryTab.transcript, initialReply)).toBe(true);
|
||||
|
||||
for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) {
|
||||
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
|
||||
const createSessionStartedAt = performance.now();
|
||||
const createdSession = await client.createWorkbenchSession(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
model,
|
||||
});
|
||||
createSessionLatencies.push(performance.now() - createSessionStartedAt);
|
||||
|
||||
await client.sendWorkbenchMessage(workspaceId, {
|
||||
taskId: created.taskId,
|
||||
tabId: createdSession.tabId,
|
||||
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
const messageStartedAt = performance.now();
|
||||
const withReply = await poll(
|
||||
`task ${runId} session ${sessionIndex} reply`,
|
||||
10 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, createdSession.tabId);
|
||||
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
|
||||
},
|
||||
);
|
||||
messageRoundTripLatencies.push(performance.now() - messageStartedAt);
|
||||
|
||||
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.tabId).transcript, expectedReply)).toBe(true);
|
||||
}
|
||||
|
||||
const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3);
|
||||
snapshotSeries.push(snapshotMetrics);
|
||||
console.info(
|
||||
"[workbench-load-snapshot]",
|
||||
JSON.stringify({
|
||||
taskIndex: taskIndex + 1,
|
||||
...snapshotMetrics,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const firstSnapshot = snapshotSeries[0]!;
|
||||
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
|
||||
const summary = {
|
||||
taskCount,
|
||||
extraSessionCount,
|
||||
createTaskAvgMs: Math.round(average(createTaskLatencies)),
|
||||
provisionAvgMs: Math.round(average(provisionLatencies)),
|
||||
createSessionAvgMs: Math.round(average(createSessionLatencies)),
|
||||
messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)),
|
||||
snapshotReadBaselineAvgMs: firstSnapshot.avgMs,
|
||||
snapshotReadFinalAvgMs: lastSnapshot.avgMs,
|
||||
snapshotReadFinalMaxMs: lastSnapshot.maxMs,
|
||||
snapshotPayloadBaselineBytes: firstSnapshot.payloadBytes,
|
||||
snapshotPayloadFinalBytes: lastSnapshot.payloadBytes,
|
||||
snapshotTabFinalCount: lastSnapshot.tabCount,
|
||||
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
|
||||
};
|
||||
|
||||
console.info("[workbench-load-summary]", JSON.stringify(summary));
|
||||
|
||||
expect(createTaskLatencies.length).toBe(taskCount);
|
||||
expect(provisionLatencies.length).toBe(taskCount);
|
||||
expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount);
|
||||
expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount);
|
||||
});
|
||||
});
|
||||
22
foundry/packages/client/test/keys.test.ts
Normal file
22
foundry/packages/client/test/keys.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "../src/keys.js";
|
||||
|
||||
describe("actor keys", () => {
|
||||
it("prefixes every key with workspace namespace", () => {
|
||||
const keys = [
|
||||
workspaceKey("default"),
|
||||
projectKey("default", "repo"),
|
||||
taskKey("default", "repo", "task"),
|
||||
sandboxInstanceKey("default", "daytona", "sbx"),
|
||||
historyKey("default", "repo"),
|
||||
projectPrSyncKey("default", "repo"),
|
||||
projectBranchSyncKey("default", "repo"),
|
||||
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1"),
|
||||
];
|
||||
|
||||
for (const key of keys) {
|
||||
expect(key[0]).toBe("ws");
|
||||
expect(key[1]).toBe("default");
|
||||
}
|
||||
});
|
||||
});
|
||||
87
foundry/packages/client/test/view-model.test.ts
Normal file
87
foundry/packages/client/test/view-model.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { filterTasks, formatRelativeAge, fuzzyMatch, summarizeTasks } from "../src/view-model.js";
|
||||
|
||||
const sample: TaskRecord = {
|
||||
workspaceId: "default",
|
||||
repoId: "repo-a",
|
||||
repoRemote: "https://example.com/repo-a.git",
|
||||
taskId: "task-1",
|
||||
branchName: "feature/test",
|
||||
title: "Test Title",
|
||||
task: "Do test",
|
||||
providerId: "daytona",
|
||||
status: "running",
|
||||
statusMessage: null,
|
||||
activeSandboxId: "sandbox-1",
|
||||
activeSessionId: "session-1",
|
||||
sandboxes: [
|
||||
{
|
||||
sandboxId: "sandbox-1",
|
||||
providerId: "daytona",
|
||||
sandboxActorId: null,
|
||||
switchTarget: "daytona://sandbox-1",
|
||||
cwd: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
agentType: null,
|
||||
prSubmitted: false,
|
||||
diffStat: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
conflictsWithMain: null,
|
||||
hasUnpushed: null,
|
||||
parentBranch: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
|
||||
describe("search helpers", () => {
|
||||
it("supports ordered fuzzy matching", () => {
|
||||
expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true);
|
||||
expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters rows across branch and title", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
sample,
|
||||
{
|
||||
...sample,
|
||||
taskId: "task-2",
|
||||
branchName: "docs/update-intro",
|
||||
title: "Docs Intro Refresh",
|
||||
status: "idle",
|
||||
},
|
||||
];
|
||||
expect(filterTasks(rows, "doc")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "h2")).toHaveLength(1);
|
||||
expect(filterTasks(rows, "test")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summary helpers", () => {
|
||||
it("formats relative age", () => {
|
||||
expect(formatRelativeAge(9_000, 10_000)).toBe("1s");
|
||||
expect(formatRelativeAge(0, 120_000)).toBe("2m");
|
||||
});
|
||||
|
||||
it("summarizes by status and provider", () => {
|
||||
const rows: TaskRecord[] = [
|
||||
sample,
|
||||
{ ...sample, taskId: "task-2", status: "idle", providerId: "daytona" },
|
||||
{ ...sample, taskId: "task-3", status: "error", providerId: "daytona" },
|
||||
];
|
||||
|
||||
const summary = summarizeTasks(rows);
|
||||
expect(summary.total).toBe(3);
|
||||
expect(summary.byStatus.running).toBe(1);
|
||||
expect(summary.byStatus.idle).toBe(1);
|
||||
expect(summary.byStatus.error).toBe(1);
|
||||
expect(summary.byProvider.daytona).toBe(3);
|
||||
});
|
||||
});
|
||||
7
foundry/packages/client/tsconfig.json
Normal file
7
foundry/packages/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue