mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 01:00:32 +00:00
feat(foundry): memory investigation tooling and VFS pool spec
Add memory monitoring instrumentation, investigation findings, and SQLite VFS pool design spec for addressing WASM SQLite memory spikes. - Add /debug/memory endpoint and periodic memory logging (dev only) - Add mem-monitor.sh script for continuous memory profiling with automatic heap snapshot capture on spike detection - Add configureRunnerPool to registry setup for engine driver support - Document memory investigation findings (per-actor cost, spike behavior) - Write SQLite VFS pool spec for bin-packing actors onto shared WASM instances - Add foundry-mem-monitor and foundry-dev-engine justfile recipes - Add compose.dev.yaml engine driver and platform support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7b23e519c2
commit
ee99d0b318
18 changed files with 888 additions and 496 deletions
|
|
@ -16,7 +16,7 @@
|
|||
"dependencies": {
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"react": "^19.1.1",
|
||||
"rivetkit": "2.1.6",
|
||||
"rivetkit": "https://pkg.pr.new/rivet-dev/rivet/rivetkit@791500a",
|
||||
"sandbox-agent": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import type {
|
|||
TaskWorkspaceSelectInput,
|
||||
TaskWorkspaceSetSessionUnreadInput,
|
||||
TaskWorkspaceSendMessageInput,
|
||||
TaskWorkspaceSnapshot,
|
||||
TaskWorkspaceSessionInput,
|
||||
TaskWorkspaceUpdateDraftInput,
|
||||
TaskEvent,
|
||||
|
|
@ -291,7 +290,6 @@ export interface BackendClient {
|
|||
getOrganizationSummary(organizationId: string): Promise<OrganizationSummarySnapshot>;
|
||||
getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTaskDetail>;
|
||||
getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkspaceSessionDetail>;
|
||||
getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot>;
|
||||
subscribeWorkspace(organizationId: string, listener: () => void): () => void;
|
||||
createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse>;
|
||||
markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise<void>;
|
||||
|
|
@ -595,91 +593,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return (await task(organizationId, repoId, taskIdValue)).getSessionDetail(await withAuthSessionInput({ sessionId }));
|
||||
};
|
||||
|
||||
const getWorkspaceCompat = async (organizationId: string): Promise<TaskWorkspaceSnapshot> => {
|
||||
const authSessionInput = await getAuthSessionInput();
|
||||
const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId });
|
||||
const resolvedTasks = await Promise.all(
|
||||
summary.taskSummaries.map(async (taskSummary) => {
|
||||
let detail;
|
||||
try {
|
||||
const taskHandle = await task(organizationId, taskSummary.repoId, taskSummary.id);
|
||||
detail = await taskHandle.getTaskDetail(authSessionInput);
|
||||
} catch (error) {
|
||||
if (isActorNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const sessionDetails = await Promise.all(
|
||||
detail.sessionsSummary.map(async (session) => {
|
||||
try {
|
||||
const full = await (await task(organizationId, detail.repoId, detail.id)).getSessionDetail({
|
||||
sessionId: session.id,
|
||||
...(authSessionInput ?? {}),
|
||||
});
|
||||
return [session.id, full] as const;
|
||||
} catch (error) {
|
||||
if (isActorNotFoundError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkspaceSessionDetail] => entry !== null));
|
||||
return {
|
||||
id: detail.id,
|
||||
repoId: detail.repoId,
|
||||
title: detail.title,
|
||||
status: detail.status,
|
||||
repoName: detail.repoName,
|
||||
updatedAtMs: detail.updatedAtMs,
|
||||
branch: detail.branch,
|
||||
pullRequest: detail.pullRequest,
|
||||
activeSessionId: detail.activeSessionId ?? null,
|
||||
sessions: detail.sessionsSummary.map((session) => {
|
||||
const full = sessionDetailsById.get(session.id);
|
||||
return {
|
||||
id: session.id,
|
||||
sessionId: session.sessionId,
|
||||
sessionName: session.sessionName,
|
||||
agent: session.agent,
|
||||
model: session.model,
|
||||
status: session.status,
|
||||
thinkingSinceMs: session.thinkingSinceMs,
|
||||
unread: session.unread,
|
||||
created: session.created,
|
||||
draft: full?.draft ?? { text: "", attachments: [], updatedAtMs: null },
|
||||
transcript: full?.transcript ?? [],
|
||||
};
|
||||
}),
|
||||
fileChanges: detail.fileChanges,
|
||||
diffs: detail.diffs,
|
||||
fileTree: detail.fileTree,
|
||||
minutesUsed: detail.minutesUsed,
|
||||
activeSandboxId: detail.activeSandboxId ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const tasks = resolvedTasks.filter((task): task is Exclude<(typeof resolvedTasks)[number], null> => task !== null);
|
||||
|
||||
const repositories = summary.repos
|
||||
.map((repo) => ({
|
||||
id: repo.id,
|
||||
label: repo.label,
|
||||
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), repo.latestActivityMs),
|
||||
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
}))
|
||||
.filter((repo) => repo.tasks.length > 0);
|
||||
|
||||
return {
|
||||
organizationId,
|
||||
repos: summary.repos.map((repo) => ({ id: repo.id, label: repo.label })),
|
||||
repositories,
|
||||
tasks: tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||
};
|
||||
};
|
||||
|
||||
const subscribeWorkspace = (organizationId: string, listener: () => void): (() => void) => {
|
||||
let entry = workspaceSubscriptions.get(organizationId);
|
||||
if (!entry) {
|
||||
|
|
@ -1225,10 +1138,6 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return await getSessionDetailWithAuth(organizationId, repoId, taskIdValue, sessionId);
|
||||
},
|
||||
|
||||
async getWorkspace(organizationId: string): Promise<TaskWorkspaceSnapshot> {
|
||||
return await getWorkspaceCompat(organizationId);
|
||||
},
|
||||
|
||||
subscribeWorkspace(organizationId: string, listener: () => void): () => void {
|
||||
return subscribeWorkspace(organizationId, listener);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,4 +8,4 @@ export * from "./subscription/use-subscription.js";
|
|||
export * from "./keys.js";
|
||||
export * from "./mock-app.js";
|
||||
export * from "./view-model.js";
|
||||
export * from "./workspace-client.js";
|
||||
export type { TaskWorkspaceClient } from "./workspace-client.js";
|
||||
|
|
|
|||
|
|
@ -654,10 +654,6 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
|
|||
return buildSessionDetail(requireTask(taskId), sessionId);
|
||||
},
|
||||
|
||||
async getWorkspace(): Promise<TaskWorkspaceSnapshot> {
|
||||
return workspace.getSnapshot();
|
||||
},
|
||||
|
||||
subscribeWorkspace(_organizationId: string, listener: () => void): () => void {
|
||||
return workspace.subscribe(listener);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
import type {
|
||||
TaskWorkspaceAddSessionResponse,
|
||||
TaskWorkspaceChangeModelInput,
|
||||
TaskWorkspaceChangeOwnerInput,
|
||||
TaskWorkspaceCreateTaskInput,
|
||||
TaskWorkspaceCreateTaskResponse,
|
||||
TaskWorkspaceDiffInput,
|
||||
TaskWorkspaceRenameInput,
|
||||
TaskWorkspaceRenameSessionInput,
|
||||
TaskWorkspaceSelectInput,
|
||||
TaskWorkspaceSetSessionUnreadInput,
|
||||
TaskWorkspaceSendMessageInput,
|
||||
TaskWorkspaceSnapshot,
|
||||
TaskWorkspaceSessionInput,
|
||||
TaskWorkspaceUpdateDraftInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "../backend-client.js";
|
||||
import { groupWorkspaceRepositories } from "../workspace-model.js";
|
||||
import type { TaskWorkspaceClient } from "../workspace-client.js";
|
||||
|
||||
export interface RemoteWorkspaceClientOptions {
|
||||
backend: BackendClient;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
class RemoteWorkspaceStore implements TaskWorkspaceClient {
|
||||
private readonly backend: BackendClient;
|
||||
private readonly organizationId: string;
|
||||
private snapshot: TaskWorkspaceSnapshot;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private unsubscribeWorkspace: (() => void) | null = null;
|
||||
private refreshPromise: Promise<void> | null = null;
|
||||
private refreshRetryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(options: RemoteWorkspaceClientOptions) {
|
||||
this.backend = options.backend;
|
||||
this.organizationId = options.organizationId;
|
||||
this.snapshot = {
|
||||
organizationId: options.organizationId,
|
||||
repos: [],
|
||||
repositories: [],
|
||||
tasks: [],
|
||||
};
|
||||
}
|
||||
|
||||
getSnapshot(): TaskWorkspaceSnapshot {
|
||||
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.unsubscribeWorkspace) {
|
||||
this.unsubscribeWorkspace();
|
||||
this.unsubscribeWorkspace = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async createTask(input: TaskWorkspaceCreateTaskInput): Promise<TaskWorkspaceCreateTaskResponse> {
|
||||
const created = await this.backend.createWorkspaceTask(this.organizationId, input);
|
||||
await this.refresh();
|
||||
return created;
|
||||
}
|
||||
|
||||
async markTaskUnread(input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
await this.backend.markWorkspaceUnread(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async renameTask(input: TaskWorkspaceRenameInput): Promise<void> {
|
||||
await this.backend.renameWorkspaceTask(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async archiveTask(input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
await this.backend.runAction(this.organizationId, input.repoId, input.taskId, "archive");
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async publishPr(input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
await this.backend.publishWorkspacePr(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async revertFile(input: TaskWorkspaceDiffInput): Promise<void> {
|
||||
await this.backend.revertWorkspaceFile(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise<void> {
|
||||
await this.backend.updateWorkspaceDraft(this.organizationId, input);
|
||||
// Skip refresh — the server broadcast will trigger it, and the frontend
|
||||
// holds local draft state to avoid the round-trip overwriting user input.
|
||||
}
|
||||
|
||||
async sendMessage(input: TaskWorkspaceSendMessageInput): Promise<void> {
|
||||
await this.backend.sendWorkspaceMessage(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async stopAgent(input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
await this.backend.stopWorkspaceSession(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async selectSession(input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
await this.backend.selectWorkspaceSession(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
|
||||
await this.backend.setWorkspaceSessionUnread(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async renameSession(input: TaskWorkspaceRenameSessionInput): Promise<void> {
|
||||
await this.backend.renameWorkspaceSession(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async closeSession(input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
await this.backend.closeWorkspaceSession(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async addSession(input: TaskWorkspaceSelectInput): Promise<TaskWorkspaceAddSessionResponse> {
|
||||
const created = await this.backend.createWorkspaceSession(this.organizationId, input);
|
||||
await this.refresh();
|
||||
return created;
|
||||
}
|
||||
|
||||
async changeModel(input: TaskWorkspaceChangeModelInput): Promise<void> {
|
||||
await this.backend.changeWorkspaceModel(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise<void> {
|
||||
await this.backend.changeWorkspaceTaskOwner(this.organizationId, input);
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
private ensureStarted(): void {
|
||||
if (!this.unsubscribeWorkspace) {
|
||||
this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => {
|
||||
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.getWorkspace(this.organizationId);
|
||||
if (this.refreshRetryTimeout) {
|
||||
clearTimeout(this.refreshRetryTimeout);
|
||||
this.refreshRetryTimeout = null;
|
||||
}
|
||||
this.snapshot = {
|
||||
...nextSnapshot,
|
||||
repositories: nextSnapshot.repositories ?? groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks),
|
||||
};
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener();
|
||||
}
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
|
||||
await this.refreshPromise;
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteWorkspaceClient(options: RemoteWorkspaceClientOptions): TaskWorkspaceClient {
|
||||
return new RemoteWorkspaceStore(options);
|
||||
}
|
||||
|
|
@ -14,17 +14,6 @@ import type {
|
|||
TaskWorkspaceSessionInput,
|
||||
TaskWorkspaceUpdateDraftInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import type { BackendClient } from "./backend-client.js";
|
||||
import { getSharedMockWorkspaceClient } from "./mock/workspace-client.js";
|
||||
import { createRemoteWorkspaceClient } from "./remote/workspace-client.js";
|
||||
|
||||
export type TaskWorkspaceClientMode = "mock" | "remote";
|
||||
|
||||
export interface CreateTaskWorkspaceClientOptions {
|
||||
mode: TaskWorkspaceClientMode;
|
||||
backend?: BackendClient;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export interface TaskWorkspaceClient {
|
||||
getSnapshot(): TaskWorkspaceSnapshot;
|
||||
|
|
@ -46,21 +35,3 @@ export interface TaskWorkspaceClient {
|
|||
changeModel(input: TaskWorkspaceChangeModelInput): Promise<void>;
|
||||
changeOwner(input: TaskWorkspaceChangeOwnerInput): Promise<void>;
|
||||
}
|
||||
|
||||
export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient {
|
||||
if (options.mode === "mock") {
|
||||
return getSharedMockWorkspaceClient();
|
||||
}
|
||||
|
||||
if (!options.backend) {
|
||||
throw new Error("Remote task workspace client requires a backend client");
|
||||
}
|
||||
if (!options.organizationId) {
|
||||
throw new Error("Remote task workspace client requires a organization id");
|
||||
}
|
||||
|
||||
return createRemoteWorkspaceClient({
|
||||
backend: options.backend,
|
||||
organizationId: options.organizationId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskWorkspaceSnapshot, WorkspaceSession, WorkspaceTask, WorkspaceModelId, WorkspaceTranscriptEvent } from "@sandbox-agent/foundry-shared";
|
||||
import type { WorkspaceSession, WorkspaceTask, WorkspaceModelId, WorkspaceTranscriptEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
import { requireImportedRepo } from "./helpers.js";
|
||||
|
||||
|
|
@ -38,12 +38,35 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
|
|||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
async function fetchFullTask(client: ReturnType<typeof createBackendClient>, organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTask> {
|
||||
const detail = await client.getTaskDetail(organizationId, repoId, taskId);
|
||||
const sessionDetails = await Promise.all(
|
||||
detail.sessionsSummary.map(async (s) => {
|
||||
const full = await client.getSessionDetail(organizationId, repoId, taskId, s.id);
|
||||
return {
|
||||
...s,
|
||||
draft: full.draft,
|
||||
transcript: full.transcript,
|
||||
} as WorkspaceSession;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: detail.id,
|
||||
repoId: detail.repoId,
|
||||
title: detail.title,
|
||||
status: detail.status,
|
||||
repoName: detail.repoName,
|
||||
updatedAtMs: detail.updatedAtMs,
|
||||
branch: detail.branch,
|
||||
pullRequest: detail.pullRequest,
|
||||
activeSessionId: detail.activeSessionId ?? null,
|
||||
sessions: sessionDetails,
|
||||
fileChanges: detail.fileChanges,
|
||||
diffs: detail.diffs,
|
||||
fileTree: detail.fileTree,
|
||||
minutesUsed: detail.minutesUsed,
|
||||
activeSandboxId: detail.activeSandboxId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
|
||||
|
|
@ -155,7 +178,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
"task provisioning",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => task.branch === `e2e/${runId}` && task.sessions.length > 0,
|
||||
);
|
||||
|
||||
|
|
@ -165,7 +188,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
"initial agent response",
|
||||
12 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, primaryTab.id);
|
||||
return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply);
|
||||
|
|
@ -219,7 +242,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
],
|
||||
});
|
||||
|
||||
const drafted = findTask(await client.getWorkspace(organizationId), created.taskId);
|
||||
const drafted = await fetchFullTask(client, organizationId, repo.repoId, created.taskId);
|
||||
expect(findTab(drafted, secondTab.sessionId).draft.text).toContain(expectedReply);
|
||||
expect(findTab(drafted, secondTab.sessionId).draft.attachments).toHaveLength(1);
|
||||
|
||||
|
|
@ -246,7 +269,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
"follow-up session response",
|
||||
10 * 60_000,
|
||||
2_000,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, secondTab.sessionId);
|
||||
return (
|
||||
|
|
@ -267,7 +290,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
});
|
||||
await client.markWorkspaceUnread(organizationId, { repoId: repo.repoId, taskId: created.taskId });
|
||||
|
||||
const unreadSnapshot = findTask(await client.getWorkspace(organizationId), created.taskId);
|
||||
const unreadSnapshot = await fetchFullTask(client, organizationId, repo.repoId, created.taskId);
|
||||
expect(unreadSnapshot.sessions.some((tab) => tab.unread)).toBe(true);
|
||||
|
||||
await client.closeWorkspaceSession(organizationId, {
|
||||
|
|
@ -280,7 +303,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
"secondary session closed",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => !task.sessions.some((tab) => tab.id === secondTab.sessionId),
|
||||
);
|
||||
expect(closedSnapshot.sessions).toHaveLength(1);
|
||||
|
|
@ -295,7 +318,7 @@ describe("e2e(client): workspace flows", () => {
|
|||
"file revert reflected in workspace",
|
||||
30_000,
|
||||
1_000,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createFoundryLogger,
|
||||
type TaskWorkspaceSnapshot,
|
||||
type WorkspaceSession,
|
||||
type WorkspaceTask,
|
||||
type WorkspaceModelId,
|
||||
|
|
@ -60,12 +59,35 @@ async function poll<T>(label: string, timeoutMs: number, intervalMs: number, fn:
|
|||
}
|
||||
}
|
||||
|
||||
function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask {
|
||||
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
|
||||
if (!task) {
|
||||
throw new Error(`task ${taskId} missing from snapshot`);
|
||||
}
|
||||
return task;
|
||||
async function fetchFullTask(client: ReturnType<typeof createBackendClient>, organizationId: string, repoId: string, taskId: string): Promise<WorkspaceTask> {
|
||||
const detail = await client.getTaskDetail(organizationId, repoId, taskId);
|
||||
const sessionDetails = await Promise.all(
|
||||
detail.sessionsSummary.map(async (s) => {
|
||||
const full = await client.getSessionDetail(organizationId, repoId, taskId, s.id);
|
||||
return {
|
||||
...s,
|
||||
draft: full.draft,
|
||||
transcript: full.transcript,
|
||||
} as WorkspaceSession;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: detail.id,
|
||||
repoId: detail.repoId,
|
||||
title: detail.title,
|
||||
status: detail.status,
|
||||
repoName: detail.repoName,
|
||||
updatedAtMs: detail.updatedAtMs,
|
||||
branch: detail.branch,
|
||||
pullRequest: detail.pullRequest,
|
||||
activeSessionId: detail.activeSessionId ?? null,
|
||||
sessions: sessionDetails,
|
||||
fileChanges: detail.fileChanges,
|
||||
diffs: detail.diffs,
|
||||
fileTree: detail.fileTree,
|
||||
minutesUsed: detail.minutesUsed,
|
||||
activeSandboxId: detail.activeSandboxId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession {
|
||||
|
|
@ -138,7 +160,7 @@ function average(values: number[]): number {
|
|||
return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1);
|
||||
}
|
||||
|
||||
async function measureWorkspaceSnapshot(
|
||||
async function measureOrganizationSummary(
|
||||
client: ReturnType<typeof createBackendClient>,
|
||||
organizationId: string,
|
||||
iterations: number,
|
||||
|
|
@ -147,35 +169,24 @@ async function measureWorkspaceSnapshot(
|
|||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
taskCount: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> {
|
||||
const durations: number[] = [];
|
||||
let snapshot: TaskWorkspaceSnapshot | null = null;
|
||||
let snapshot: Awaited<ReturnType<typeof client.getOrganizationSummary>> | null = null;
|
||||
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const startedAt = performance.now();
|
||||
snapshot = await client.getWorkspace(organizationId);
|
||||
snapshot = await client.getOrganizationSummary(organizationId);
|
||||
durations.push(performance.now() - startedAt);
|
||||
}
|
||||
|
||||
const finalSnapshot = snapshot ?? {
|
||||
organizationId,
|
||||
repos: [],
|
||||
repositories: [],
|
||||
tasks: [],
|
||||
};
|
||||
const finalSnapshot = snapshot ?? { organizationId, github: {} as any, repos: [], taskSummaries: [] };
|
||||
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
|
||||
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.sessions.length, 0);
|
||||
const transcriptEventCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.sessions.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,
|
||||
taskCount: finalSnapshot.taskSummaries.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -204,11 +215,9 @@ describe("e2e(client): workspace load", () => {
|
|||
avgMs: number;
|
||||
maxMs: number;
|
||||
payloadBytes: number;
|
||||
tabCount: number;
|
||||
transcriptEventCount: number;
|
||||
}> = [];
|
||||
|
||||
snapshotSeries.push(await measureWorkspaceSnapshot(client, organizationId, 2));
|
||||
snapshotSeries.push(await measureOrganizationSummary(client, organizationId, 2));
|
||||
|
||||
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
|
||||
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
|
||||
|
|
@ -229,7 +238,7 @@ describe("e2e(client): workspace load", () => {
|
|||
`task ${runId} provisioning`,
|
||||
12 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => {
|
||||
const tab = task.sessions[0];
|
||||
return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply));
|
||||
|
|
@ -264,7 +273,7 @@ describe("e2e(client): workspace load", () => {
|
|||
`task ${runId} session ${sessionIndex} reply`,
|
||||
10 * 60_000,
|
||||
pollIntervalMs,
|
||||
async () => findTask(await client.getWorkspace(organizationId), created.taskId),
|
||||
async () => fetchFullTask(client, organizationId, repo.repoId, created.taskId),
|
||||
(task) => {
|
||||
const tab = findTab(task, createdSession.sessionId);
|
||||
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
|
||||
|
|
@ -275,7 +284,7 @@ describe("e2e(client): workspace load", () => {
|
|||
expect(transcriptIncludesAgentText(findTab(withReply, createdSession.sessionId).transcript, expectedReply)).toBe(true);
|
||||
}
|
||||
|
||||
const snapshotMetrics = await measureWorkspaceSnapshot(client, organizationId, 3);
|
||||
const snapshotMetrics = await measureOrganizationSummary(client, organizationId, 3);
|
||||
snapshotSeries.push(snapshotMetrics);
|
||||
logger.info(
|
||||
{
|
||||
|
|
@ -300,8 +309,7 @@ describe("e2e(client): workspace load", () => {
|
|||
snapshotReadFinalMaxMs: lastSnapshot.maxMs,
|
||||
snapshotPayloadBaselineBytes: firstSnapshot.payloadBytes,
|
||||
snapshotPayloadFinalBytes: lastSnapshot.payloadBytes,
|
||||
snapshotTabFinalCount: lastSnapshot.tabCount,
|
||||
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
|
||||
snapshotTaskFinalCount: lastSnapshot.taskCount,
|
||||
};
|
||||
|
||||
logger.info(summary, "workspace_load_summary");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue