mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
fix(workspaces): preserve portable runtime readiness
This commit is contained in:
@@ -136,6 +136,7 @@ test.describe("New workspace entry points", () => {
|
||||
sourceDirectory: "/tmp/missing-project",
|
||||
});
|
||||
|
||||
await expect(page.getByRole("button", { name: "Retry" })).toBeVisible({ timeout: 30_000 });
|
||||
await expectNewWorkspaceControlsEnabled(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -856,6 +856,8 @@ interface ComposerProps {
|
||||
submitIcon?: "arrow" | "return";
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** Disables submission without locking draft editing or attachments. */
|
||||
isSubmitDisabled?: boolean;
|
||||
/** When true, waits for pasted GitHub links to resolve before enabling submit. */
|
||||
waitForGithubAutoAttachOnSubmit?: boolean;
|
||||
submitBehavior?: "clear" | "preserve-and-lock";
|
||||
@@ -1067,6 +1069,7 @@ export function Composer({
|
||||
submitButtonTestID,
|
||||
submitIcon = "arrow",
|
||||
isSubmitLoading = false,
|
||||
isSubmitDisabled = false,
|
||||
waitForGithubAutoAttachOnSubmit = false,
|
||||
submitBehavior = "clear",
|
||||
blurOnSubmit = false,
|
||||
@@ -2144,8 +2147,10 @@ export function Composer({
|
||||
|
||||
const isSubmitLoadingVisible =
|
||||
isProcessing || isSubmitLoading || isUploadingFile || pendingNativeImagePastes > 0;
|
||||
const isSubmitDisabled =
|
||||
isSubmitLoadingVisible || (waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
const isSubmitActionDisabled =
|
||||
isSubmitDisabled ||
|
||||
isSubmitLoadingVisible ||
|
||||
(waitForGithubAutoAttachOnSubmit && githubAutoAttach.isResolving);
|
||||
|
||||
// Disable drops while submitting/uploading: the submit path clears and restores attachments,
|
||||
// so a drop in that window would be lost or land on a locked draft. `disabled` hides the
|
||||
@@ -2204,7 +2209,7 @@ export function Composer({
|
||||
submitButtonAccessibilityLabel={submitButtonAccessibilityLabel}
|
||||
submitButtonTestID={submitButtonTestID}
|
||||
submitIcon={submitIcon}
|
||||
isSubmitDisabled={isSubmitDisabled}
|
||||
isSubmitDisabled={isSubmitActionDisabled}
|
||||
isSubmitLoading={isSubmitLoadingVisible}
|
||||
preserveHeightOnSubmit={submitBehavior === "preserve-and-lock"}
|
||||
attachments={selectedAttachments}
|
||||
|
||||
@@ -2487,7 +2487,8 @@ export function NewWorkspaceScreen({
|
||||
allowEmptySubmit={true}
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
|
||||
submitButtonTestID="new-workspace-launch-submit"
|
||||
isSubmitLoading={isCreationBlocked}
|
||||
isSubmitLoading={isPending}
|
||||
isSubmitDisabled={isCreationBlocked}
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
value={terminalComposerValue}
|
||||
@@ -2511,7 +2512,8 @@ export function NewWorkspaceScreen({
|
||||
submitButtonAccessibilityLabel={t("newWorkspace.create")}
|
||||
submitButtonTestID="workspace-create-submit"
|
||||
submitIcon="return"
|
||||
isSubmitLoading={isCreationBlocked}
|
||||
isSubmitLoading={isPending}
|
||||
isSubmitDisabled={isCreationBlocked}
|
||||
waitForGithubAutoAttachOnSubmit
|
||||
submitBehavior="preserve-and-lock"
|
||||
blurOnSubmit={true}
|
||||
|
||||
@@ -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);
|
||||
}, 30_000);
|
||||
|
||||
test("failed Hub create cleans durable state when provider close rejects", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2494,21 +2494,26 @@ test("non-git workspace uses deterministic directory name and no unknown branch
|
||||
|
||||
test("workspace placements preserve checkout facts independently from the project", async () => {
|
||||
const session = createSessionForWorkspaceTests();
|
||||
const mainRepoRoot = path.resolve("/tmp/main-repo");
|
||||
const manualWorktreeCwd = path.resolve("/tmp/manual-worktree");
|
||||
const explicitDirectoryCwd = path.resolve("/tmp/plain-directory");
|
||||
const paseoWorktreeRoot = path.resolve("/tmp/paseo-worktree");
|
||||
const paseoSubdirectoryCwd = path.join(paseoWorktreeRoot, "packages", "app");
|
||||
const manualWorktree = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-manual-worktree",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/manual-worktree",
|
||||
cwd: manualWorktreeCwd,
|
||||
kind: "worktree",
|
||||
displayName: "manual",
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
mainRepoRoot,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const explicitDirectory = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-explicit-directory",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/plain-directory",
|
||||
cwd: explicitDirectoryCwd,
|
||||
kind: "directory",
|
||||
displayName: "plain",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -2517,17 +2522,17 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
const paseoSubdirectory = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-paseo-subdirectory",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/paseo-worktree/packages/app",
|
||||
cwd: paseoSubdirectoryCwd,
|
||||
kind: "worktree",
|
||||
displayName: "app",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
mainRepoRoot,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-manual-worktree",
|
||||
rootPath: "/tmp/main-repo",
|
||||
rootPath: mainRepoRoot,
|
||||
kind: "git",
|
||||
displayName: "main",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
@@ -2541,7 +2546,7 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
session.workspaceGitService.peekSnapshot = (cwd: string) =>
|
||||
cwd === paseoSubdirectory.cwd
|
||||
? createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: { repoRoot: "/tmp/paseo-worktree" },
|
||||
git: { repoRoot: paseoWorktreeRoot },
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -2552,7 +2557,7 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
checkout: expect.objectContaining({
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
mainRepoRoot,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -2573,7 +2578,7 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
expect.objectContaining({
|
||||
checkout: expect.objectContaining({
|
||||
cwd: paseoSubdirectory.cwd,
|
||||
worktreeRoot: "/tmp/paseo-worktree",
|
||||
worktreeRoot: paseoWorktreeRoot,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -24,6 +24,14 @@ function eventWithin(event: Promise<void>, label: string): Promise<void> {
|
||||
]);
|
||||
}
|
||||
|
||||
function createDeferred(): { promise: Promise<void>; resolve(): void } {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
cleanupRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
|
||||
@@ -105,6 +113,69 @@ test.each(["local", "worktree"] as const)(
|
||||
20_000,
|
||||
);
|
||||
|
||||
test("workspace Git disposal waits for runtime observation teardown", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-runtime-git-dispose-"));
|
||||
cleanupRoots.push(root);
|
||||
const source = await createRepository(path.join(root, "source"));
|
||||
const workspaceId = "runtime-git-dispose";
|
||||
const runtimeIds = new Map<string, string>();
|
||||
const workspaceRuntime = createWorkspaceRuntimeService({
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
resolveRuntimeId: async (id) => runtimeIds.get(id) ?? null,
|
||||
persistRuntimeId: async (id, runtimeId) => {
|
||||
runtimeIds.set(id, runtimeId);
|
||||
},
|
||||
beginWorkspaceDeletion: async () => {},
|
||||
removeWorkspaceRecord: async (id) => {
|
||||
runtimeIds.delete(id);
|
||||
},
|
||||
});
|
||||
await workspaceRuntime.create({
|
||||
workspaceId,
|
||||
runtimeId: "local",
|
||||
project: { id: "dispose-project", source: { kind: "host-directory", path: source } },
|
||||
placement: { kind: "existing" },
|
||||
});
|
||||
|
||||
const boundRuntime = await workspaceRuntime.bind(workspaceId);
|
||||
const originalSubscribe = boundRuntime.files.subscribe.bind(boundRuntime.files);
|
||||
const unsubscribeStarted = createDeferred();
|
||||
const unsubscribeReleased = createDeferred();
|
||||
boundRuntime.files.subscribe = async (...args) => {
|
||||
const subscription = await originalSubscribe(...args);
|
||||
return {
|
||||
unsubscribe: async () => {
|
||||
unsubscribeStarted.resolve();
|
||||
await unsubscribeReleased.promise;
|
||||
await subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const workspaceGit = new WorkspaceGitServiceImpl({
|
||||
logger: createLogger(),
|
||||
paseoHome: path.join(root, "paseo-home"),
|
||||
workspaceRuntime,
|
||||
});
|
||||
const selectedGit = workspaceGit.bindWorkspace({ workspaceId, cwd: source });
|
||||
await selectedGit.observe(() => undefined);
|
||||
|
||||
const disposal = workspaceGit.dispose();
|
||||
try {
|
||||
await eventWithin(unsubscribeStarted.promise, "runtime observation teardown");
|
||||
expect(
|
||||
await Promise.race([
|
||||
disposal.then(() => "disposed" as const),
|
||||
Promise.resolve("pending" as const),
|
||||
]),
|
||||
).toBe("pending");
|
||||
} finally {
|
||||
unsubscribeReleased.resolve();
|
||||
}
|
||||
await disposal;
|
||||
await workspaceRuntime.destroy(workspaceId);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["local", "pause", "resume"],
|
||||
["worktree", "archive", "restore"],
|
||||
|
||||
@@ -1425,9 +1425,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.selectedWorkspaces.clear();
|
||||
this.legacyWorkspaces.clear();
|
||||
|
||||
for (const target of this.workspaceTargets.values()) {
|
||||
this.closeWorkspaceTarget(target);
|
||||
}
|
||||
const workspaceTargetDisposals = [...this.workspaceTargets.values()].map((target) =>
|
||||
this.closeWorkspaceTarget(target),
|
||||
);
|
||||
this.workspaceTargets.clear();
|
||||
|
||||
for (const target of this.repoTargets.values()) {
|
||||
@@ -1448,6 +1448,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.disposePromise = Promise.all([
|
||||
this.fileObserver.close(),
|
||||
...selectedWorkspaceDisposals,
|
||||
...workspaceTargetDisposals,
|
||||
]).then(() => undefined);
|
||||
return this.disposePromise;
|
||||
}
|
||||
@@ -3750,7 +3751,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
}
|
||||
|
||||
this.closeWorkspaceTarget(target);
|
||||
void this.closeWorkspaceTarget(target);
|
||||
this.workspaceTargets.delete(target.cwd);
|
||||
}
|
||||
|
||||
@@ -3796,9 +3797,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.workingTreeWatchTargets.delete(target.cwd);
|
||||
}
|
||||
|
||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): Promise<void> {
|
||||
target.closed = true;
|
||||
void target.runtimeObservation?.unsubscribe();
|
||||
const runtimeObservationDisposal =
|
||||
target.runtimeObservation?.unsubscribe() ?? Promise.resolve();
|
||||
target.runtimeObservation = null;
|
||||
if (target.workingTreeWatchTarget) {
|
||||
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
||||
@@ -3814,6 +3816,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
this.stopForgePrStatusPollForTarget(target);
|
||||
target.listeners.clear();
|
||||
return runtimeObservationDisposal;
|
||||
}
|
||||
|
||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||
|
||||
+63
-61
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { expect, test } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
createFileObserver,
|
||||
@@ -13,72 +13,74 @@ import {
|
||||
} from "../../file-observer/index.js";
|
||||
import { createHostGitObservationOwner } from "./host-git-observation.js";
|
||||
|
||||
test("sibling host worktrees share one physical common-Git observation", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-"));
|
||||
try {
|
||||
const source = await repository(path.join(root, "source"));
|
||||
const sibling = path.join(root, "sibling");
|
||||
git(source, ["branch", "sibling"]);
|
||||
git(source, ["worktree", "add", sibling, "sibling"]);
|
||||
const unrelated = await repository(path.join(root, "unrelated"));
|
||||
const owner = createHostGitObservationOwner();
|
||||
describe.runIf(process.platform !== "win32")("host Git observation on POSIX", () => {
|
||||
test("sibling host worktrees share one physical common-Git observation", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-observation-"));
|
||||
try {
|
||||
const source = await repository(path.join(root, "source"));
|
||||
const sibling = path.join(root, "sibling");
|
||||
git(source, ["branch", "sibling"]);
|
||||
git(source, ["worktree", "add", sibling, "sibling"]);
|
||||
const unrelated = await repository(path.join(root, "unrelated"));
|
||||
const owner = createHostGitObservationOwner();
|
||||
|
||||
const [sourceSubscription, siblingSubscription] = await Promise.all([
|
||||
owner.observe(source, () => undefined),
|
||||
owner.observe(sibling, () => undefined),
|
||||
]);
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
|
||||
const [sourceSubscription, siblingSubscription] = await Promise.all([
|
||||
owner.observe(source, () => undefined),
|
||||
owner.observe(sibling, () => undefined),
|
||||
]);
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
|
||||
|
||||
const unrelatedSubscription = await owner.observe(unrelated, () => undefined);
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
|
||||
const unrelatedSubscription = await owner.observe(unrelated, () => undefined);
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
|
||||
|
||||
await sourceSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
|
||||
await siblingSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
|
||||
await unrelatedSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
await sourceSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(2);
|
||||
await siblingSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(1);
|
||||
await unrelatedSubscription.unsubscribe();
|
||||
expect(owner.getDiagnostics().activeObservationCount).toBe(0);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failed physical common watcher rebinds once without changing logical ownership", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-"));
|
||||
try {
|
||||
const source = await repository(path.join(root, "source"));
|
||||
const sibling = path.join(root, "sibling");
|
||||
git(source, ["branch", "sibling"]);
|
||||
git(source, ["worktree", "add", sibling, "sibling"]);
|
||||
const observer = new InstrumentedFileObserver();
|
||||
const owner = createHostGitObservationOwner(observer);
|
||||
let notifications = 0;
|
||||
const subscriptions = await Promise.all([
|
||||
owner.observe(source, () => {
|
||||
notifications += 1;
|
||||
}),
|
||||
owner.observe(sibling, () => {
|
||||
notifications += 1;
|
||||
}),
|
||||
]);
|
||||
expect(observer.subscribeCount).toBe(1);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
|
||||
test("a failed physical common watcher rebinds once without changing logical ownership", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-host-git-rebind-"));
|
||||
try {
|
||||
const source = await repository(path.join(root, "source"));
|
||||
const sibling = path.join(root, "sibling");
|
||||
git(source, ["branch", "sibling"]);
|
||||
git(source, ["worktree", "add", sibling, "sibling"]);
|
||||
const observer = new InstrumentedFileObserver();
|
||||
const owner = createHostGitObservationOwner(observer);
|
||||
let notifications = 0;
|
||||
const subscriptions = await Promise.all([
|
||||
owner.observe(source, () => {
|
||||
notifications += 1;
|
||||
}),
|
||||
owner.observe(sibling, () => {
|
||||
notifications += 1;
|
||||
}),
|
||||
]);
|
||||
expect(observer.subscribeCount).toBe(1);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
|
||||
|
||||
observer.failActive(new Error("physical watcher failed"));
|
||||
const barrier = await owner.observe(sibling, () => undefined);
|
||||
expect(notifications).toBe(2);
|
||||
expect(observer.subscribeCount).toBe(2);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
|
||||
observer.failActive(new Error("physical watcher failed"));
|
||||
const barrier = await owner.observe(sibling, () => undefined);
|
||||
expect(notifications).toBe(2);
|
||||
expect(observer.subscribeCount).toBe(2);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(1);
|
||||
|
||||
await Promise.all([
|
||||
...subscriptions.map((subscription) => subscription.unsubscribe()),
|
||||
barrier.unsubscribe(),
|
||||
]);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(0);
|
||||
await observer.close();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
await Promise.all([
|
||||
...subscriptions.map((subscription) => subscription.unsubscribe()),
|
||||
barrier.unsubscribe(),
|
||||
]);
|
||||
expect(observer.getDiagnostics().activeObservationCount).toBe(0);
|
||||
await observer.close();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
class InstrumentedFileObserver implements FileObserver {
|
||||
|
||||
Reference in New Issue
Block a user