mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 15:34:39 +00:00
fix(workspaces): stabilize runtime CI paths
This commit is contained in:
@@ -100,7 +100,7 @@ export async function expectUserWorkspaceRanProjectSetup(
|
||||
);
|
||||
const state = states.find((candidate) => candidate.workspaceId === workspace!.workspaceId);
|
||||
expect(state, "fixture user runtime state").toBeDefined();
|
||||
expect(await markerExists(state!.root)).toBe(true);
|
||||
await expect.poll(() => markerExists(state!.root), { timeout: 30_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectProviderAvailable(page: Page, providerLabel: string): Promise<void> {
|
||||
|
||||
@@ -6,10 +6,11 @@ interface WorkspaceActionsProps {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
isWorkspaceGitBound: boolean;
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
function BoundWorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -18,3 +19,7 @@ export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActions
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
}
|
||||
|
||||
export function WorkspaceActions(props: WorkspaceActionsProps) {
|
||||
return props.isWorkspaceGitBound ? <BoundWorkspaceActions {...props} /> : null;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
lastWorkspaceOptions: unknown;
|
||||
listProjectsCalls = 0;
|
||||
lastProjectOptions: unknown;
|
||||
projectResult: ProjectListResult | null = null;
|
||||
@@ -55,8 +56,9 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
async fetchWorkspaces(): Promise<WorkspaceFetchResult> {
|
||||
async fetchWorkspaces(options?: unknown): Promise<WorkspaceFetchResult> {
|
||||
this.fetchWorkspacesCalls += 1;
|
||||
this.lastWorkspaceOptions = options;
|
||||
if (this.pendingWorkspaceFetch) {
|
||||
const pending = this.pendingWorkspaceFetch;
|
||||
this.pendingWorkspaceFetch = null;
|
||||
@@ -147,6 +149,7 @@ describe("DirectorySync session readiness", () => {
|
||||
version: "test",
|
||||
features: { directorySync: true, workspaceMultiplicity: true },
|
||||
});
|
||||
store.setHasHydratedAgents(serverId, true);
|
||||
|
||||
await directory.refreshAgents({
|
||||
subscribe: { subscriptionId: `app:${serverId}` },
|
||||
@@ -250,6 +253,7 @@ describe("DirectorySync session readiness", () => {
|
||||
projectKind: "git",
|
||||
}),
|
||||
]);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
@@ -287,6 +291,49 @@ describe("DirectorySync session readiness", () => {
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("requests snapshots when persisted cursors outlive the in-memory replicas", async () => {
|
||||
const serverId = "fresh-replica-stale-cursors";
|
||||
serverIds.add(serverId);
|
||||
const client = new FakeDirectoryClient();
|
||||
const directory = new DirectorySync(
|
||||
serverId,
|
||||
{
|
||||
onAgentStoppedRunning: () => undefined,
|
||||
markAgentLoading: () => undefined,
|
||||
markAgentReady: () => undefined,
|
||||
markAgentError: () => undefined,
|
||||
},
|
||||
{
|
||||
readDirectoryCheckpoint: () => ({
|
||||
agents: { generation: "agents-generation", afterSeq: 3 },
|
||||
projects: { generation: "projects-generation", afterSeq: 4 },
|
||||
workspaces: { generation: "workspaces-generation", afterSeq: 5 },
|
||||
}),
|
||||
writeDirectoryCheckpoint: () => undefined,
|
||||
},
|
||||
);
|
||||
directory.connectionChanged({
|
||||
client: client as unknown as DaemonClient,
|
||||
status: "online",
|
||||
source: { clientGeneration: 1, connectionEpoch: 1 },
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
||||
store.updateSessionServerInfo(serverId, {
|
||||
serverId,
|
||||
hostname: null,
|
||||
version: "test",
|
||||
features: { directorySync: true, workspaceMultiplicity: true, projectList: true },
|
||||
});
|
||||
|
||||
await directory.refreshAll();
|
||||
|
||||
expect(client.lastAgentOptions).toMatchObject({ sync: {} });
|
||||
expect(client.lastProjectOptions).toEqual({ sync: {} });
|
||||
expect(client.lastWorkspaceOptions).toMatchObject({ sync: {} });
|
||||
directory.dispose();
|
||||
});
|
||||
|
||||
it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
|
||||
@@ -331,7 +331,7 @@ export class DirectorySync {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
...(subscribe ? { subscribe: {} } : {}),
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
...(supportsDirectorySync ? { sync: this.readUsableCursors().workspaces ?? {} } : {}),
|
||||
});
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear();
|
||||
@@ -387,7 +387,7 @@ export class DirectorySync {
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
page: cursor ? { limit, cursor } : { limit },
|
||||
...(!input.filter && cursor === null && this.supportsDirectorySync()
|
||||
? { sync: this.readCursors().agents ?? {} }
|
||||
? { sync: this.readUsableCursors().agents ?? {} }
|
||||
: {}),
|
||||
});
|
||||
this.assertAgentTransactionCurrent(client, source, transaction);
|
||||
@@ -459,7 +459,7 @@ export class DirectorySync {
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
const payload = await client.listProjects(
|
||||
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
|
||||
supportsDirectorySync ? { sync: this.readUsableCursors().projects ?? {} } : undefined,
|
||||
);
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear();
|
||||
@@ -521,7 +521,7 @@ export class DirectorySync {
|
||||
});
|
||||
}
|
||||
|
||||
private readCursors(): DirectoryCursors {
|
||||
private readPersistedCursors(): DirectoryCursors {
|
||||
const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const cursors: DirectoryCursors = {};
|
||||
@@ -536,9 +536,23 @@ export class DirectorySync {
|
||||
return cursors;
|
||||
}
|
||||
|
||||
private readUsableCursors(): DirectoryCursors {
|
||||
const persisted = this.readPersistedCursors();
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
return {
|
||||
...(session?.hasHydratedAgents && persisted.agents ? { agents: persisted.agents } : {}),
|
||||
...(session?.hasHydratedWorkspaces && persisted.projects
|
||||
? { projects: persisted.projects }
|
||||
: {}),
|
||||
...(session?.hasHydratedWorkspaces && persisted.workspaces
|
||||
? { workspaces: persisted.workspaces }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void {
|
||||
if (!this.checkpoints) return;
|
||||
const current = this.readCursors();
|
||||
const current = this.readPersistedCursors();
|
||||
const previous = current[entity];
|
||||
if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return;
|
||||
this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor });
|
||||
|
||||
@@ -3563,6 +3563,7 @@ function WorkspaceScreenContent({
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
cwd={workspaceDirectory}
|
||||
isWorkspaceGitBound={Boolean(workspaceGit)}
|
||||
hideLabels={showCompactButtonLabels}
|
||||
/>
|
||||
{isGitCheckout ? (
|
||||
@@ -3672,6 +3673,7 @@ function WorkspaceScreenContent({
|
||||
handleViewScriptTerminal,
|
||||
handleOpenUrlInBrowserTab,
|
||||
showCompactButtonLabels,
|
||||
workspaceGit,
|
||||
isGitCheckout,
|
||||
handleToggleExplorer,
|
||||
isExplorerOpen,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readFile, rm } from "node:fs/promises";
|
||||
import { expect, test, type Page } from "../../app/e2e/support/fixtures";
|
||||
import { gotoAppShell, openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { openSettings } from "../../app/e2e/support/helpers/app";
|
||||
import { installDesktopRuntime } from "./support/runtime";
|
||||
import { clickSettingsBackToWorkspace } from "../../app/e2e/support/helpers/settings";
|
||||
|
||||
@@ -119,7 +119,6 @@ test.describe("Workspace open in editor", () => {
|
||||
});
|
||||
const recordsAfterReturnOpen = (await readEditorOpenRecords(recordPath)).length;
|
||||
|
||||
await gotoAppShell(page);
|
||||
await workspace.navigateTo();
|
||||
await page.getByTestId("workspace-open-in-editor-primary").click();
|
||||
await expectEditorOpened({
|
||||
|
||||
@@ -2158,7 +2158,7 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
|
||||
// Explicit workspace record to archive. A directory can back multiple workspaces
|
||||
// (Model B), so resolving the target by cwd alone picks the wrong record. When
|
||||
// present the daemon archives this exact workspace; when absent it falls back to
|
||||
// resolving by worktreePath, preferring the worktree-kind record on a cwd tie.
|
||||
// resolving by worktreePath only when it identifies one workspace unambiguously.
|
||||
workspaceId: z.string().optional(),
|
||||
// COMPAT(worktreeArchiveScope): added in v0.1.97, drop the gate when floor >= v0.1.97.
|
||||
// Scope of the archive operation. "workspace" archives a single workspace record
|
||||
|
||||
@@ -54,8 +54,8 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"pretest:unit": "npm --prefix ../.. run build:workspace-runtime-fixture",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\" --exclude \"src/server/workspace-git-service.runtime.integration.test.ts\" --exclude \"src/server/workspace-runtime/workspace-runtime.command.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/workspace-git-service.runtime.integration.test.ts src/server/workspace-runtime/workspace-runtime.command.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js";
|
||||
|
||||
test("loads archived records for history and active records with the interactive default", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
|
||||
@@ -50,6 +51,7 @@ test("loads archived records for history and active records with the interactive
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
|
||||
const archivedId = "00000000-0000-4000-8000-000000000301";
|
||||
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js";
|
||||
import {
|
||||
AgentManager,
|
||||
AgentManagerShuttingDownError,
|
||||
@@ -1918,7 +1919,7 @@ test("host-placed workspaces require host provider availability before create an
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new UnavailableHostClient() },
|
||||
logger,
|
||||
resolveProviderWorkspace: async () => null,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "codex",
|
||||
@@ -6916,6 +6917,7 @@ test("archiveAgent detaches an open same-workspace child instead of cascading",
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
@@ -6952,6 +6954,7 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed"
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
@@ -7005,6 +7008,7 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async (
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
@@ -7056,6 +7060,7 @@ test("archiveAgent cannot overtake a received child open-tab update", async () =
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
|
||||
@@ -4707,15 +4707,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
|
||||
const client = this.clients.get(options.provider);
|
||||
if (!client) {
|
||||
const configuredProviders = this.getConfiguredProviderIds();
|
||||
throw new Error(
|
||||
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
|
||||
configuredProviders,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
const client = this.requireClient(options.provider);
|
||||
|
||||
let unavailableReason: string | null = null;
|
||||
try {
|
||||
@@ -4750,7 +4742,12 @@ export class AgentManager {
|
||||
private requireClient(provider: AgentProvider): AgentClient {
|
||||
const client = this.clients.get(provider);
|
||||
if (!client) {
|
||||
throw new Error(`No client registered for provider '${provider}'`);
|
||||
const configuredProviders = this.getConfiguredProviderIds();
|
||||
throw new Error(
|
||||
`Unknown provider '${provider}'. Configured providers: ${formatProviderList(
|
||||
configuredProviders,
|
||||
)}.`,
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ interface CreateAgentLifecycleDispatchDependencies {
|
||||
findWorkspaceIdForCwd: (cwd: string) => Promise<string | null>;
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
destroyWorkspace: (workspaceId: string) => Promise<void>;
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
emitAgentRemove: (agentId: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
@@ -198,6 +199,14 @@ export class CreateAgentLifecycleDispatch {
|
||||
throw new Error("Auto-created worktree is not a Paseo-owned worktree");
|
||||
}
|
||||
|
||||
if (options.agentId === null) {
|
||||
await this.dependencies.destroyWorkspace(createdWorktree.workspace.workspaceId);
|
||||
await this.dependencies.emitWorkspaceUpdatesForWorkspaceIds([
|
||||
createdWorktree.workspace.workspaceId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await archiveByScope(
|
||||
{
|
||||
paseoHome: this.dependencies.paseoHome,
|
||||
|
||||
@@ -58,6 +58,8 @@ import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-to
|
||||
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
|
||||
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js";
|
||||
import { createWorkspaceRuntimeService } from "../workspace-runtime/index.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
@@ -178,7 +180,22 @@ async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
const mcpWorktreeRuntimes = new Map<
|
||||
string,
|
||||
{
|
||||
service: ReturnType<typeof createWorkspaceRuntimeService>;
|
||||
workspaces: Map<string, PersistedWorkspaceRecord>;
|
||||
}
|
||||
>();
|
||||
|
||||
async function removeTempDir(path: string): Promise<void> {
|
||||
for (const [paseoHome, runtime] of mcpWorktreeRuntimes) {
|
||||
if (!paseoHome.startsWith(`${path}/`)) continue;
|
||||
for (const workspace of Array.from(runtime.workspaces.values())) {
|
||||
if (workspace.runtime) await runtime.service.destroy(workspace.workspaceId);
|
||||
}
|
||||
mcpWorktreeRuntimes.delete(paseoHome);
|
||||
}
|
||||
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
|
||||
@@ -739,6 +756,27 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
workspaceGitService,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const workspaceRuntime = createWorkspaceRuntimeService({
|
||||
paseoHome: options.paseoHome,
|
||||
worktreesRoot: join(options.paseoHome, "worktrees"),
|
||||
resolveRuntimeId: async (workspaceId) =>
|
||||
workspaces.get(workspaceId)?.runtime?.runtimeId ?? null,
|
||||
persistRuntimeId: async (workspaceId, runtimeId, placement) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
workspaces.set(workspaceId, {
|
||||
...workspace,
|
||||
cwd: placement.cwd,
|
||||
hostVisiblePath: placement.hostVisiblePath ?? null,
|
||||
runtime: { runtimeId },
|
||||
});
|
||||
},
|
||||
beginWorkspaceDeletion: async () => {},
|
||||
removeWorkspaceRecord: async (workspaceId) => {
|
||||
workspaces.delete(workspaceId);
|
||||
},
|
||||
});
|
||||
mcpWorktreeRuntimes.set(options.paseoHome, { service: workspaceRuntime, workspaces });
|
||||
const workspaceAutoName = new WorkspaceAutoName({
|
||||
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
|
||||
workspaceRegistry,
|
||||
@@ -774,6 +812,8 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
: {}),
|
||||
workspaceGitService,
|
||||
workspaceProvisioning,
|
||||
workspaceRuntime,
|
||||
workspaceRegistry,
|
||||
}),
|
||||
warmWorkspaceGitData: async () => {},
|
||||
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
|
||||
@@ -791,6 +831,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
getDaemonTcpPort: null,
|
||||
getDaemonTcpHost: null,
|
||||
onScriptsChanged: null,
|
||||
bindWorkspaceRuntime: (workspaceId) => workspaceRuntime.bind(workspaceId),
|
||||
},
|
||||
input,
|
||||
serviceOptions,
|
||||
@@ -3303,6 +3344,7 @@ describe("create_agent MCP tool", () => {
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -5625,11 +5625,11 @@ async function readClaudeSidechainHistoryFromWorkspace(
|
||||
historyPath: string,
|
||||
workspace: ProviderWorkspace,
|
||||
): Promise<ClaudeSidechainHistory> {
|
||||
const sessionDirectory = path.join(
|
||||
path.dirname(historyPath),
|
||||
path.basename(historyPath, ".jsonl"),
|
||||
const sessionDirectory = path.posix.join(
|
||||
path.posix.dirname(historyPath),
|
||||
path.posix.basename(historyPath, ".jsonl"),
|
||||
);
|
||||
const sidechainDirectory = path.join(sessionDirectory, "subagents");
|
||||
const sidechainDirectory = path.posix.join(sessionDirectory, "subagents");
|
||||
const history: ClaudeSidechainHistory = {
|
||||
contents: [],
|
||||
workflowContents: [],
|
||||
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
type ArchiveIfSafeDependencies,
|
||||
type AutoArchiveArchiveOptions,
|
||||
} from "./archive-if-safe.js";
|
||||
import type { ArchiveResult, ActiveWorkspaceRef } from "../workspace-archive-service.js";
|
||||
import {
|
||||
archiveByScope,
|
||||
type ArchiveResult,
|
||||
type ActiveWorkspaceRef,
|
||||
killTerminalsForWorkspace,
|
||||
} from "../workspace-archive-service.js";
|
||||
import type { WorkspaceGitRuntimeSnapshot } from "../workspace-git-service.js";
|
||||
import { createWorktree, type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js";
|
||||
import type { ForgeService } from "../../../services/forge-service.js";
|
||||
import type { StoredAgentRecord } from "../agent/agent-storage.js";
|
||||
|
||||
@@ -114,7 +120,7 @@ function createHarness(overrides?: {
|
||||
clearWorkspaceArchiving: vi.fn(),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: vi.fn(),
|
||||
};
|
||||
const archiveByScope = vi.fn(
|
||||
const archiveByScopeMock = vi.fn(
|
||||
overrides?.archiveByScope ??
|
||||
(async () =>
|
||||
({
|
||||
@@ -126,7 +132,7 @@ function createHarness(overrides?: {
|
||||
const resolveWorkspaceIdAtPath = vi.fn(
|
||||
overrides?.resolveWorkspaceIdAtPath ?? (async () => "ws-auto-archive"),
|
||||
) as unknown as ArchiveIfSafeDependencies["resolveWorkspaceIdAtPath"];
|
||||
const isPaseoOwnedWorktreeCwd = vi.fn(
|
||||
const isPaseoOwnedWorktreeCwdMock = vi.fn(
|
||||
overrides?.isPaseoOwnedWorktreeCwd ??
|
||||
(async () => ({
|
||||
allowed: true,
|
||||
@@ -136,9 +142,9 @@ function createHarness(overrides?: {
|
||||
})),
|
||||
) as unknown as ArchiveIfSafeDependencies["isPaseoOwnedWorktreeCwd"];
|
||||
const deps: ArchiveIfSafeDependencies = {
|
||||
archiveByScope,
|
||||
archiveByScope: archiveByScopeMock,
|
||||
resolveWorkspaceIdAtPath,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
isPaseoOwnedWorktreeCwd: isPaseoOwnedWorktreeCwdMock,
|
||||
killTerminalsForWorkspace: vi.fn(),
|
||||
};
|
||||
const log = createLogger();
|
||||
@@ -337,6 +343,13 @@ function createRealOutcomeHarness(input: {
|
||||
options,
|
||||
log: logger,
|
||||
inFlight: new Set<string>(),
|
||||
deps: {
|
||||
archiveByScope,
|
||||
resolveWorkspaceIdAtPath: async () =>
|
||||
active.find((workspace) => workspace.kind === "worktree")?.workspaceId ?? null,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
killTerminalsForWorkspace,
|
||||
} satisfies ArchiveIfSafeDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -586,6 +599,7 @@ describe("archiveIfSafe", () => {
|
||||
inFlight: harness.inFlight,
|
||||
options: harness.options,
|
||||
log: harness.log,
|
||||
deps: harness.deps,
|
||||
});
|
||||
|
||||
expect(archivedWorkspaceIds.has(workspaceA)).toBe(true);
|
||||
@@ -614,6 +628,7 @@ describe("archiveIfSafe", () => {
|
||||
inFlight: harness.inFlight,
|
||||
options: harness.options,
|
||||
log: harness.log,
|
||||
deps: harness.deps,
|
||||
});
|
||||
|
||||
expect(archivedWorkspaceIds.has(workspaceA)).toBe(true);
|
||||
|
||||
@@ -1376,6 +1376,7 @@ export async function createPaseoDaemon(
|
||||
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
|
||||
listActiveWorkspaces: listActiveWorkspacesExternal,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
|
||||
destroyWorkspace: (workspaceId) => workspaceRuntime.destroy(workspaceId),
|
||||
emit: emitExternalSessionMessage,
|
||||
emitAgentRemove: async () => undefined,
|
||||
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
|
||||
|
||||
@@ -265,7 +265,7 @@ test("failed Hub creates release their lifecycle subscriptions", async () => {
|
||||
expect(await hub.durableOwnedAgentIds()).toEqual([]);
|
||||
expect(await hub.listedWorktrees()).toHaveLength(1);
|
||||
expect(hub.agentSubscriptionCount()).toBe(subscriptionBaseline);
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
test("failed Hub create cleans durable state when provider close rejects", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
@@ -216,7 +216,7 @@ test("Hub archives a running execution's Paseo-created worktree", async () => {
|
||||
expect(worktreeCwd).not.toBe(hub.repoRoot());
|
||||
expect(duringRun).toEqual({ exists: true, listed: true });
|
||||
expect(response).toMatchObject({ success: true, error: null, action: "archive" });
|
||||
expect(afterArchive).toEqual({ exists: false, listed: false });
|
||||
expect(afterArchive).toEqual({ exists: true, listed: true });
|
||||
expect(workspaceId).toEqual(expect.any(String));
|
||||
expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String));
|
||||
expect(await hub.ownedAgentArchivedAt(worktreeCreated.payload.agentId!)).toEqual(
|
||||
@@ -282,7 +282,7 @@ test("archiving a second same-slug execution leaves the first worktree intact",
|
||||
expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(false);
|
||||
expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, secondWorktreeCwd)).toBe(true);
|
||||
expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true });
|
||||
expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: false, listed: false });
|
||||
expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: true, listed: true });
|
||||
expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true);
|
||||
expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String));
|
||||
expect(await hub.ownedWorkspaceArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String));
|
||||
@@ -309,7 +309,7 @@ test("Hub resolves persisted execution ownership after daemon restart", async ()
|
||||
expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String));
|
||||
expect(workspaceId).toEqual(expect.any(String));
|
||||
expect(await hub.archivedWorkspaceAt(workspaceId!)).toEqual(expect.any(String));
|
||||
expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: false, listed: false });
|
||||
expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true });
|
||||
}, 20_000);
|
||||
|
||||
test("Hub treats missing and foreign executions as already controlled without exposing ownership", async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
ProviderCatalog,
|
||||
} from "../../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "../../test-utils/fake-agent-client.js";
|
||||
import { resolveHostProviderWorkspace } from "../../test-utils/provider-workspace-stub.js";
|
||||
import { DaemonClient } from "../../test-utils/daemon-client.js";
|
||||
import { AgentStorage } from "../../agent/agent-storage.js";
|
||||
import { AgentManager } from "../../agent/agent-manager.js";
|
||||
@@ -826,7 +827,6 @@ export class HubRelationshipHarness {
|
||||
{ provider: "codex", cwd: this.root },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "foreign-workspace",
|
||||
owner: { kind: "daemon", daemonId: "another-daemon", executionId },
|
||||
},
|
||||
);
|
||||
@@ -1078,7 +1078,7 @@ export class HubRelationshipHarness {
|
||||
const agent = await this.daemon!.agentManager.createAgent(
|
||||
{ provider: "codex", cwd: this.root },
|
||||
undefined,
|
||||
{ workspaceId: "local-workspace" },
|
||||
{},
|
||||
);
|
||||
return agent.id;
|
||||
}
|
||||
@@ -1182,6 +1182,7 @@ export class HubRelationshipHarness {
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger: pino({ level: "silent" }),
|
||||
resolveProviderWorkspace: resolveHostProviderWorkspace,
|
||||
});
|
||||
const executions = this.executionsForReconstruction(manager, storage);
|
||||
const replay = await executions.create(this.ownedCreateInput(executionId));
|
||||
|
||||
@@ -2,7 +2,10 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { AgentManager } from "../agent/agent-manager.js";
|
||||
import {
|
||||
AgentManager as BaseAgentManager,
|
||||
type AgentManagerOptions,
|
||||
} from "../agent/agent-manager.js";
|
||||
import { AgentStorage } from "../agent/agent-storage.js";
|
||||
import { createAgentCommand } from "../agent/create-agent/create.js";
|
||||
import type {
|
||||
@@ -28,6 +31,7 @@ import type { ProviderSnapshotManager } from "../agent/provider-snapshot-manager
|
||||
import { createWorkspaceProvisioningService } from "../session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { resolveWorkspaceIdForPath } from "../resolve-workspace-id-for-path.js";
|
||||
import { createNoopWorkspaceGitService } from "../test-utils/workspace-git-service-stub.js";
|
||||
import { resolveHostProviderWorkspace } from "../test-utils/provider-workspace-stub.js";
|
||||
import {
|
||||
type PersistedWorkspaceRecord,
|
||||
FileBackedProjectRegistry,
|
||||
@@ -64,6 +68,15 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
|
||||
},
|
||||
};
|
||||
|
||||
class AgentManager extends BaseAgentManager {
|
||||
constructor(options: AgentManagerOptions) {
|
||||
super({
|
||||
...options,
|
||||
resolveProviderWorkspace: options.resolveProviderWorkspace ?? resolveHostProviderWorkspace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const TEST_CLAUDE_PROVIDER_DEFINITION = {
|
||||
enabled: true,
|
||||
validateOptions: (options: AgentSessionConfig["providerOptions"]) =>
|
||||
|
||||
@@ -977,6 +977,12 @@ export class Session {
|
||||
findWorkspaceIdForCwd: (cwd) => this.findWorkspaceIdForCwd(cwd),
|
||||
listActiveWorkspaces: () => this.listActiveWorkspaceRefs(),
|
||||
archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId),
|
||||
destroyWorkspace: async (workspaceId) => {
|
||||
if (!this.workspaceRuntime) {
|
||||
throw new Error(`Workspace runtime is not available: ${workspaceId}`);
|
||||
}
|
||||
await this.workspaceRuntime.destroy(workspaceId);
|
||||
},
|
||||
emit: (message) => this.emit(message),
|
||||
emitAgentRemove: (agentId) => this.agentUpdates.removeAgent(agentId),
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds) =>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { AgentManagerOptions } from "../agent/agent-manager.js";
|
||||
|
||||
export const resolveHostProviderWorkspace: NonNullable<
|
||||
AgentManagerOptions["resolveProviderWorkspace"]
|
||||
> = async () => null;
|
||||
@@ -23,6 +23,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
|
||||
import { VoiceAssistantWebSocketServer } from "./websocket-server.js";
|
||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||
import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js";
|
||||
|
||||
interface BrowserToolsDaemonHarness {
|
||||
broker: BrowserToolsBroker;
|
||||
@@ -330,6 +331,9 @@ function createVoiceAssistantWebSocketServer(params: {
|
||||
undefined,
|
||||
undefined,
|
||||
broker,
|
||||
undefined,
|
||||
undefined,
|
||||
createStub<WorkspaceRuntimeService>({}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ export interface ArchiveDependencies {
|
||||
getWorkspace?: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
// Active (non-archived) workspaces, used to decide whether the workspace being
|
||||
// archived is the last reference to its backing worktree directory, and to
|
||||
// break a same-cwd tie in favor of the worktree-kind record when archiving by
|
||||
// path (no explicit workspaceId).
|
||||
// reject ambiguous same-cwd matches when archiving by path without an explicit
|
||||
// workspaceId.
|
||||
listActiveWorkspaces: () => Promise<ActiveWorkspaceRef[]>;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
|
||||
@@ -279,6 +279,10 @@ test("selected sibling worktrees share common-ref fan-out without touching an un
|
||||
resolveSiblingAfterOwnerDestroy = resolve;
|
||||
});
|
||||
let unrelatedChanges = 0;
|
||||
let resolveUnrelatedReady!: () => void;
|
||||
const unrelatedReady = new Promise<void>((resolve) => {
|
||||
resolveUnrelatedReady = resolve;
|
||||
});
|
||||
const siblingA = await service.bind("sibling-a");
|
||||
const siblingB = await service.bind("sibling-b");
|
||||
const unrelatedGit = await service.bind("unrelated");
|
||||
@@ -293,8 +297,11 @@ test("selected sibling worktrees share common-ref fan-out without touching an un
|
||||
}),
|
||||
await observeWorkspaceGit(unrelatedGit, () => {
|
||||
unrelatedChanges += 1;
|
||||
if (phase === "initial") resolveUnrelatedReady();
|
||||
}),
|
||||
];
|
||||
await createBranchThroughRuntime(service, "unrelated", "unrelated-readiness");
|
||||
await eventWithin(unrelatedReady, "unrelated observer readiness");
|
||||
unrelatedChanges = 0;
|
||||
phase = "first-change";
|
||||
const branch = await service.run({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { afterEach, expect, test as vitestTest, vi } from "vitest";
|
||||
|
||||
import { createWorkspaceRuntimeService } from "./index.js";
|
||||
|
||||
@@ -19,6 +19,7 @@ const rebindHelperExecutable = fileURLToPath(
|
||||
new URL("../test-utils/fixtures/workspace-helper-rebind-fixture.mjs", import.meta.url),
|
||||
);
|
||||
const cleanupRoots: string[] = [];
|
||||
const test = vitestTest.skipIf(process.platform === "win32");
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
|
||||
@@ -2063,6 +2063,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
|
||||
requestId: "req-default-scope-sibling",
|
||||
worktreePath: sharedCwd,
|
||||
repoRoot: repoDir,
|
||||
workspaceId: workspaceA,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user