mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
fix: preserve automatic workspace fallback for missing Boat files
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9c583bf39d
commit
ff2598c3d8
2 files changed
+88
-13
No files matched your search
@@ -248,6 +248,71 @@ describeEmbeddedPostgres("workspace file resources", () => {
|
||||
} finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each(["project", "discovery"])("falls through a missing persistent file to the existing automatic %s candidate", async (fallback) => {
|
||||
const workspace = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot: workspace.projectRoot, targetProjectRoot: workspace.targetProjectRoot, executionRoot: workspace.executionRoot });
|
||||
const environmentId = crypto.randomUUID(), placementId = crypto.randomUUID();
|
||||
await db.update(executionWorkspaces).set({ providerType: "computer", cwd: workspace.executionRoot,
|
||||
metadata: { fileAuthority: { kind: "remote-persistent", environmentId, placementId } } })
|
||||
.where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
|
||||
const root = fallback === "project" ? workspace.projectRoot : workspace.targetProjectRoot;
|
||||
const workspaceId = fallback === "project" ? graph.projectWorkspaceId : graph.targetProjectWorkspaceId;
|
||||
await fs.mkdir(path.join(root, "docs"));
|
||||
await fs.writeFile(path.join(root, "docs", "guide.md"), "legitimate project fallback");
|
||||
// A controller path with the same spelling is never the placement's authority.
|
||||
await fs.mkdir(path.join(workspace.executionRoot, "docs"));
|
||||
await fs.writeFile(path.join(workspace.executionRoot, "docs", "guide.md"), "stale controller copy");
|
||||
const missing = Object.assign(new Error("Remote file missing"), { code: "not_found" });
|
||||
const stat = vi.fn().mockRejectedValue(missing);
|
||||
const listPage = vi.fn().mockRejectedValue(missing);
|
||||
const readBytes = vi.fn();
|
||||
const workspaceFiles = vi.fn(async () => ({ root: workspace.executionRoot, stat, listPage, readBytes }));
|
||||
const factory = vi.spyOn(computerModule, "computerService").mockReturnValue({ workspaceFiles } as never);
|
||||
try {
|
||||
const service = workspaceFileResourceService(db);
|
||||
const input = { path: "docs/guide.md" };
|
||||
expect(await service.resolve(graph.issueId, input)).toMatchObject({ workspaceId });
|
||||
expect(await service.readContent(graph.issueId, input)).toMatchObject({ resource: { workspaceId }, content: { data: "legitimate project fallback" } });
|
||||
expect(await service.prepareDownload(graph.issueId, input)).toMatchObject({ resource: { workspaceId }, realPath: path.join(root, "docs", "guide.md") });
|
||||
const availability = await service.availability(graph.issueId, { queries: [input] });
|
||||
expect(availability.results[0]).toMatchObject({ openable: true, resource: { workspaceId } });
|
||||
expect(await service.list(graph.issueId, { path: "docs/" })).toMatchObject({ items: [expect.objectContaining({ workspaceId, title: "guide.md" })] });
|
||||
expect(readBytes).not.toHaveBeenCalled();
|
||||
expect(workspaceFiles).toHaveBeenCalledWith({ companyId: graph.companyId, environmentId, placementId });
|
||||
for (const method of [service.resolve, service.readContent, service.prepareDownload]) {
|
||||
await expect(method(graph.issueId, { ...input, workspace: "execution" })).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
await expect(service.list(graph.issueId, { path: "docs/", workspace: "execution" })).rejects.toMatchObject({ status: 404 });
|
||||
expect((await service.availability(graph.issueId, { queries: [{ ...input, workspace: "execution" }] })).results[0])
|
||||
.toMatchObject({ openable: false, unavailableReason: "not_found", resource: null });
|
||||
} finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it.each([["forbidden", 403], ["provider_error", 502]] as const)("does not fall back from a persistent %s error", async (code, status) => {
|
||||
const workspace = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot: workspace.projectRoot, executionRoot: workspace.executionRoot });
|
||||
await db.update(executionWorkspaces).set({ providerType: "computer", cwd: workspace.executionRoot,
|
||||
metadata: { fileAuthority: { kind: "remote-persistent", environmentId: crypto.randomUUID(), placementId: crypto.randomUUID() } } })
|
||||
.where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
|
||||
await fs.mkdir(path.join(workspace.projectRoot, "docs"));
|
||||
await fs.writeFile(path.join(workspace.projectRoot, "docs", "guide.md"), "must not be returned");
|
||||
const failure = Object.assign(new Error("Remote access failed"), { code });
|
||||
const workspaceFiles = vi.fn(async () => ({ stat: vi.fn().mockRejectedValue(failure), listPage: vi.fn().mockRejectedValue(failure) }));
|
||||
const factory = vi.spyOn(computerModule, "computerService").mockReturnValue({ workspaceFiles } as never);
|
||||
try {
|
||||
const service = workspaceFileResourceService(db);
|
||||
for (const method of [service.resolve, service.readContent, service.prepareDownload]) {
|
||||
await expect(method(graph.issueId, { path: "docs/guide.md" })).rejects.toMatchObject({ status });
|
||||
}
|
||||
await expect(service.list(graph.issueId, { path: "docs/" })).rejects.toMatchObject({ status });
|
||||
if (code === "forbidden") {
|
||||
expect((await service.availability(graph.issueId, { queries: [{ path: "docs/guide.md" }] })).results[0]).toMatchObject({ openable: false, resource: null });
|
||||
} else {
|
||||
await expect(service.availability(graph.issueId, { queries: [{ path: "docs/guide.md" }] })).rejects.toMatchObject({ status });
|
||||
}
|
||||
} finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
it("withholds private task files and private cross-project targets", async () => {
|
||||
const { projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
|
||||
const graph = await seedGraph(db, { projectRoot, executionRoot, targetProjectRoot });
|
||||
|
||||
@@ -1385,14 +1385,14 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
const hasExplicitTarget = Boolean(explicitTarget?.candidate);
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.placement) return availabilityResult(item.query, await statPersistentCandidate(candidate, item.normalizedPath, item.directory));
|
||||
if (candidate.remote) {
|
||||
if (candidate.remote && !candidate.placement) {
|
||||
if (hasExplicitTarget || item.query.workspace !== "auto") {
|
||||
return availabilityResult(item.query, remoteResource(candidate, item.normalizedPath.relativePath));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (candidate.placement) return availabilityResult(item.query, await statPersistentCandidate(candidate, item.normalizedPath, item.directory));
|
||||
const resource = item.directory
|
||||
? (await statLocalDirectory(candidate, item.normalizedPath)).resource
|
||||
: (await statLocalCandidate(candidate, item.normalizedPath)).resource;
|
||||
@@ -1454,12 +1454,12 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.placement) return statPersistentCandidate(candidate, normalized, isDirectoryRequest);
|
||||
if (candidate.remote) {
|
||||
if (candidate.remote && !candidate.placement) {
|
||||
if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (candidate.placement) return await statPersistentCandidate(candidate, normalized, isDirectoryRequest);
|
||||
return isDirectoryRequest
|
||||
? (await statLocalDirectory(candidate, normalized)).resource
|
||||
: (await statLocalCandidate(candidate, normalized)).resource;
|
||||
@@ -1513,7 +1513,17 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null;
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.placement) return persistentList(candidate, { selector, mode, normalizedPath, q, normalizedQuery, limit, offset });
|
||||
if (candidate.placement) {
|
||||
try {
|
||||
return await persistentList(candidate, { selector, mode, normalizedPath, q, normalizedQuery, limit, offset });
|
||||
} catch (error) {
|
||||
if (normalizedPath && !explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
lastNotFound = error;
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (candidate.remote) {
|
||||
firstUnavailable ??= { candidate, reason: "remote_workspace" };
|
||||
if (explicitTarget || selector !== "auto") {
|
||||
@@ -1720,8 +1730,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.placement) return persistentContent(candidate, normalized);
|
||||
if (candidate.remote) {
|
||||
if (candidate.remote && !candidate.placement) {
|
||||
if (explicitTarget || selector !== "auto") {
|
||||
throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" });
|
||||
}
|
||||
@@ -1729,6 +1738,7 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
}
|
||||
let resolved: LocalResolvedFile;
|
||||
try {
|
||||
if (candidate.placement) return await persistentContent(candidate, normalized);
|
||||
resolved = await statLocalCandidate(candidate, normalized);
|
||||
} catch (error) {
|
||||
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
@@ -1805,18 +1815,18 @@ export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor,
|
||||
|
||||
let lastNotFound: unknown = null;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.placement) {
|
||||
const resource = await statPersistentCandidate(candidate, normalized);
|
||||
const { bytes } = await (await placementFiles(candidate)).readBytes(normalized.relativePath);
|
||||
return { resource: { ...resource, byteSize: bytes.length }, bytes };
|
||||
}
|
||||
if (candidate.remote) {
|
||||
if (candidate.remote && !candidate.placement) {
|
||||
if (explicitTarget || selector !== "auto") {
|
||||
throw unprocessable("Remote workspaces cannot be downloaded by the server", { code: "remote_workspace" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (candidate.placement) {
|
||||
const resource = await statPersistentCandidate(candidate, normalized);
|
||||
const { bytes } = await (await placementFiles(candidate)).readBytes(normalized.relativePath);
|
||||
return { resource: { ...resource, byteSize: bytes.length }, bytes };
|
||||
}
|
||||
return await statLocalCandidate(candidate, normalized);
|
||||
} catch (error) {
|
||||
if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) {
|
||||
|
||||
Reference in new issue
Block a user