mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
fix(creation): recover draft retries without accepting partial workspaces
This commit is contained in:
@@ -180,20 +180,22 @@ test("workspace receipts serialize concurrent requests and survive reconstructio
|
||||
).not.toBe("wks_first");
|
||||
});
|
||||
|
||||
test("a workspace persisted before a lost acknowledgement is recovered", async () => {
|
||||
test("a workspace record left by failed provisioning is not mistaken for completion", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
const workspaces = new Set<string>();
|
||||
const input = {
|
||||
key: "lost-workspace",
|
||||
key: "partial-workspace",
|
||||
workspaceId: "wks_created",
|
||||
request: {},
|
||||
findWorkspace: async (id: string) => workspaces.has(id),
|
||||
create: async (id: string) => {
|
||||
workspaces.add(id);
|
||||
throw new Error("acknowledgement lost");
|
||||
throw new Error("checkout rolled back");
|
||||
},
|
||||
};
|
||||
await expect(requests.createWorkspace(input)).rejects.toThrow("acknowledgement lost");
|
||||
expect(await new RequestReceipts(directory).createWorkspace(input)).toBe("wks_created");
|
||||
await expect(requests.createWorkspace(input)).rejects.toThrow("checkout rolled back");
|
||||
await expect(new RequestReceipts(directory).createWorkspace(input)).rejects.toThrow(
|
||||
"workspace_request_outcome_unknown",
|
||||
);
|
||||
expect([...workspaces]).toEqual(["wks_created"]);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,9 @@ export class RequestReceipts {
|
||||
}): Promise<string> {
|
||||
return this.execute(["create-workspace", input.key], input.request, {
|
||||
resource: { kind: "workspace", id: input.workspaceId },
|
||||
recover: input.findWorkspace,
|
||||
// A registry entry can survive failed provisioning and checkout rollback.
|
||||
// Only a completed receipt proves that the entire operation succeeded.
|
||||
recover: async () => false,
|
||||
run: input.create,
|
||||
retrySafe: async (workspaceId) => !(await input.findWorkspace(workspaceId)),
|
||||
});
|
||||
|
||||
@@ -6350,19 +6350,6 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const create = async (workspaceId?: string) => {
|
||||
let creationRequest = request;
|
||||
// Hooks belong to the operation: retries fingerprint the caller's input
|
||||
// and must not rerun hooks or compare their potentially changing output.
|
||||
if (this.pluginRuntime) {
|
||||
const { type, requestId, ...input } = request;
|
||||
const transformed = await this.pluginRuntime.before("workspace.create", input);
|
||||
creationRequest = { ...transformed, type, requestId };
|
||||
}
|
||||
return creationRequest.source.kind === "directory"
|
||||
? this.handleWorkspaceCreateLocal(creationRequest, workspaceId)
|
||||
: this.handleWorkspaceCreateWorktree(creationRequest, workspaceId);
|
||||
};
|
||||
let descriptor: WorkspaceDescriptorPayload | undefined;
|
||||
if (request.idempotencyKey !== undefined) {
|
||||
const { requestId: _requestId, idempotencyKey, ...payload } = request;
|
||||
@@ -6372,16 +6359,24 @@ export class Session {
|
||||
workspaceId: generateWorkspaceId(),
|
||||
findWorkspace: async (id) => (await this.workspaceRegistry.get(id)) !== null,
|
||||
create: async (id) => {
|
||||
descriptor = await create(id);
|
||||
descriptor = await this.createRequestedWorkspace(request, id);
|
||||
},
|
||||
});
|
||||
if (!descriptor) {
|
||||
const workspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!workspace) throw new Error("Previously created workspace no longer exists");
|
||||
if (!(await this.filesystem.isDirectory(workspace.cwd))) {
|
||||
throw new SessionRequestError(
|
||||
"directory_not_found",
|
||||
`Directory not found: ${workspace.cwd}`,
|
||||
);
|
||||
}
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
|
||||
await this.emitCreatedWorkspaceUpdate(descriptor);
|
||||
}
|
||||
} else {
|
||||
descriptor = await create();
|
||||
descriptor = await this.createRequestedWorkspace(request);
|
||||
}
|
||||
const workspace = await this.workspaceRegistry.get(descriptor.id);
|
||||
this.emit({
|
||||
@@ -6423,6 +6418,23 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async createRequestedWorkspace(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
workspaceId?: string,
|
||||
): Promise<WorkspaceDescriptorPayload> {
|
||||
let creationRequest = request;
|
||||
// Hooks belong to the operation: retries fingerprint the caller's input
|
||||
// and must not rerun hooks or compare their potentially changing output.
|
||||
if (this.pluginRuntime) {
|
||||
const { type, requestId, ...input } = request;
|
||||
const transformed = await this.pluginRuntime.before("workspace.create", input);
|
||||
creationRequest = { ...transformed, type, requestId };
|
||||
}
|
||||
return creationRequest.source.kind === "directory"
|
||||
? this.handleWorkspaceCreateLocal(creationRequest, workspaceId)
|
||||
: this.handleWorkspaceCreateWorktree(creationRequest, workspaceId);
|
||||
}
|
||||
|
||||
private async handleWorkspaceCreateLocal(
|
||||
request: Extract<SessionInboundMessage, { type: "workspace.create.request" }>,
|
||||
workspaceId?: string,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -49,3 +49,30 @@ test("workspace.create surfaces each early-reject error branch", async () => {
|
||||
rmSync(missingDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
test("workspace creation replay rejects a checkout removed after completion", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "workspace-replay-"));
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const request = {
|
||||
source: { kind: "directory" as const, path: directory },
|
||||
idempotencyKey: "removed-checkout",
|
||||
};
|
||||
const created = await client.createWorkspace(request);
|
||||
expect(created.error).toBeNull();
|
||||
expect(created.workspace).not.toBeNull();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
const replayed = await client.createWorkspace(request);
|
||||
expect(replayed.workspace).toBeNull();
|
||||
expect(replayed.errorCode).toBe("directory_not_found");
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
|
||||
Reference in New Issue
Block a user