mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
fix(runner): paginate workspace discovery before journal persistence (#19311)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native agents discover reusable workspaces through `list_workspaces`. > - The tool returned every authorized workspace in one result. > - A large inventory exceeded the durable command journal limit. The result stayed pending, and session cleanup blocked the next turn. > - This pull request limits discovery in SQL and returns explicit continuation metadata. > - Agents can inspect every authorized workspace without sending the full inventory in one command. ## Linked Issues or Issue Description Related: #15749 introduced the task workspace tools. No duplicate pagination fix was found. **What happened?** A native workspace discovery call returned a multi-megabyte inventory. Persisting its result failed with `command_payload_too_large`. The preceding answer could still be saved, but cleanup retained the unsettled tool call. A later turn failed before provider work with `native_session_cleanup_quarantined`. **Expected behavior** Workspace discovery must return a manageable page. Agents must be able to request the remaining authorized rows. Existing visibility checks and journal integrity checks must stay effective. **Steps to reproduce** 1. Create 2,000 task-selectable workspaces with ordinary repository paths. 2. Call `list_workspaces` from an admitted native run. 3. On the old code, the real API regression returns 869,781 bytes. The command journal allows about 510 KiB. 4. On the repaired code, follow `nextCursor` to inspect all rows. Pages contain at most 100 rows and fit within 128 KiB. **Paperclip version or commit** Reproduced on source commit `7351a9d74f` before the repair. **Deployment mode** Isolated source checkout with real PostgreSQL and authenticated native agent APIs. Hosted deployment and incident recovery remain separate. ## What Changed - Validate workspace list limits and UUID cursors with a JSON Schema pattern supported by strict validators. Return `workspaces`, `limit`, `returnedCount`, `hasMore`, and `nextCursor`. - Apply company, visibility, lifecycle, and cursor conditions before the SQL limit. Order pages by workspace ID. Fetch one extra row to detect continuation without a count query. - Fit encoded pages within 128 KiB, including JSON escaping and Unicode. If one summary cannot fit, return explicit string previews with `truncatedFields`. The authorized detail API retains its full record. - Keep existing unpaged summary callers on their array response and recent-use order. - Update the native tool descriptor, generated contracts, catalog fixture, and execution documentation. - Add regression coverage for large inventories, complete pagination, stable pages, private and foreign rows, invalid inputs, and legacy summary behavior. Add an opt-in two-turn live Codex acceptance check. ## Verification - Red before repair: `task-workspace-api.test.ts -t "pages a large authorized workspace inventory"` failed because the result was 869,781 bytes instead of below 128 KiB. The transport regression also failed at 2,284,671 bytes. - Green after repair and review changes: 120 tests passed across the workspace API, transport, routes, native authority, coordinator, historical-question, and Codex vertical-slice suites. They cover complete discovery, byte-sized pages, oversized individual summaries, full detail retrieval, private/foreign/lifecycle exclusions, and strict schema compatibility. - Journal safety: two focused durable control-plane tests passed. Oversized result rejection and lossless valid result delivery remain effective. - Tool catalog and reconciliation: 30 tests passed. - `pnpm -r typecheck`: passed. - `pnpm build`: passed again on final commit `bc10880da6`. Server typecheck also passed after the review changes. The original affected suites passed on the rebased first commit (31 tests), and the expanded review regression set passed afterward (120 tests). - Real provider acceptance passed again on final commit `bc10880da6`: local macOS arm64, rebuilt native Runner, `gpt-6.1-sol`, high effort, per-turn lifecycle, and 2,000 persisted workspaces. Both turns succeeded. Persisted call inputs and results prove that the second page uses the first result’s cursor, both calls succeed, their IDs do not overlap, and the encoded results stay within 128 KiB. The second turn kept the provider conversation and file. Both task locks were clear after completion, and no recovered execution failure was recorded. Duration: 166.92 seconds. - Full local `pnpm test:run` stopped in the chat shard: 330 tests passed and one Slack registration test failed with `socket hang up`. That exact test passed in isolation without changes, and the final-head CI chat shards passed. A broader local managed-Slack follow-up produced no result after about eight minutes and was interrupted; it is not counted as a pass. The original full-suite failure remains recorded. - The first CI head failed native tool-schema compilation because strict validators did not recognize `format: uuid`. The revised descriptor uses a UUID pattern. The previously failing coordinator, native vertical-slice, authority, and history suites now pass locally. The stronger live cursor-receipt acceptance check passed. Final-head CI completed on `bc10880da6`: 57 checks passed and five optional checks were skipped, with no failures or pending checks. Greptile reviewed that commit at 5/5; both prior findings are resolved. - Dependency cost: paged discovery runs the existing visibility-scoped summary query with an ID cursor and at most 101 returned rows. It adds no count query, filesystem scan, provider call, or background worker. ## Risks - Native `list_workspaces` now returns a page object instead of an array. Individual summaries can be explicit previews when a single record exceeds the byte budget. Its advertised descriptor and generated contracts change together. Agents must follow `nextCursor` until null for a complete inventory. - Each page rechecks current visibility. Concurrent insertions, deletions, or access changes require a fresh scan when a complete point-in-time inventory is needed. - No migration or journal-limit change. An existing cleanup quarantine still requires normal recovery after deployment; this PR does not bypass it. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, shell tools, code editing, and test execution. The exact served model revision and context window are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a6f786479e
commit
55389554c2
14 files changed
+378
-28
No files matched your search
@@ -171,6 +171,23 @@ describe("execution workspace routes", () => {
|
||||
}, expect.anything());
|
||||
});
|
||||
|
||||
it("bounds summary discovery after company and visibility checks", async () => {
|
||||
const cursor = "00000000-0000-4000-8000-000000000001";
|
||||
const res = await request(createApp())
|
||||
.get(`/api/companies/company-1/execution-workspaces?summary=true&selectableForTask=true&limit=51&cursor=${cursor}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockExecutionWorkspaceService.listSummaries).toHaveBeenCalledWith("company-1", expect.objectContaining({
|
||||
readCondition: expect.anything(), selectableForTask: true,
|
||||
}), { limit: 51, cursor });
|
||||
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["limit=1000", "limit=0", "limit=1.5", "cursor=invalid"])("rejects invalid summary pagination (%s)", async query => {
|
||||
const res = await request(createApp()).get(`/api/companies/company-1/execution-workspaces?summary=true&${query}`);
|
||||
expect(res.status).toBe(422);
|
||||
expect(mockExecutionWorkspaceService.listSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects invalid workspace overview pagination", async () => {
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/workspace-overview?limit=1000");
|
||||
|
||||
@@ -151,7 +151,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
...definition, companyId: f.companyId, projectId: null, strategyType: "task_directory", cwd: f.workspace,
|
||||
}))).returning();
|
||||
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
|
||||
const listed = await call("list_workspaces", {}) as Array<{ id: string }>;
|
||||
const { workspaces: listed } = await call("list_workspaces", {}) as { workspaces: Array<{ id: string }> };
|
||||
const candidates = new Set(rows.slice(0, 3).map(row => row.id));
|
||||
expect(listed.filter(row => rows.some(candidate => candidate.id === row.id)).map(row => row.id).sort()).toEqual([...candidates].sort());
|
||||
for (const row of rows) {
|
||||
@@ -162,6 +162,72 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
else await expect(selection).rejects.toThrow("Workspace is unavailable or inaccessible");
|
||||
}
|
||||
});
|
||||
it("pages a large authorized workspace inventory without exceeding the Runner command bound", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const timestamp = new Date("2026-01-01T00:00:00Z");
|
||||
const rows = await server.db.insert(executionWorkspaces).values(Array.from({ length: 2_000 }, (_, index) => ({
|
||||
companyId: f.companyId, name: `Workspace ${index}`, mode: "shared_workspace", strategyType: "task_directory",
|
||||
cwd: `/tmp/${"repository/".repeat(20)}${index}`, lastUsedAt: timestamp, createdAt: timestamp,
|
||||
}))).returning({ id: executionWorkspaces.id });
|
||||
await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Archived", mode: "shared_workspace",
|
||||
strategyType: "task_directory", cwd: f.workspace, status: "archived" });
|
||||
const [privateTask] = await server.db.insert(issues).values({ companyId: f.companyId, title: "Private source",
|
||||
visibility: "private", status: "backlog" }).returning({ id: issues.id });
|
||||
await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: privateTask.id,
|
||||
name: "Private workspace", mode: "shared_workspace", strategyType: "task_directory", cwd: f.workspace });
|
||||
const foreign = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
await server.db.insert(executionWorkspaces).values({ companyId: foreign.companyId, name: "Foreign", mode: "shared_workspace",
|
||||
strategyType: "task_directory", cwd: foreign.workspace });
|
||||
type Page = { workspaces: Array<{ id: string }>; limit: number; returnedCount: number; hasMore: boolean; nextCursor: string | null };
|
||||
const list = (args: Record<string, unknown>) => f.authority.execute({ tool: "list_workspaces", arguments: args, callId: randomUUID() }) as Promise<Page>;
|
||||
let page = await list({});
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(128 * 1024);
|
||||
expect(page).toMatchObject({ limit: 50, returnedCount: 50, hasMore: true });
|
||||
const seen = page.workspaces.map(workspace => workspace.id);
|
||||
const first = page;
|
||||
expect(await list({})).toEqual(first);
|
||||
while (page.nextCursor) {
|
||||
const cursor = page.nextCursor;
|
||||
page = await list({ cursor, limit: 100 });
|
||||
expect(page.nextCursor === null || page.nextCursor > cursor).toBe(true);
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(128 * 1024);
|
||||
expect(page.returnedCount).toBe(page.workspaces.length);
|
||||
seen.push(...page.workspaces.map(workspace => workspace.id));
|
||||
}
|
||||
expect(page.hasMore).toBe(false);
|
||||
expect(seen).toEqual(rows.map(row => row.id).sort());
|
||||
expect(new Set(seen).size).toBe(rows.length);
|
||||
await expect(list({ limit: 1_000 })).rejects.toThrow();
|
||||
await expect(list({ cursor: "not-a-workspace-id" })).rejects.toThrow();
|
||||
});
|
||||
it("bounds encoded summary bytes and preserves discovery past oversized individual records", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
const giantId = "00000000-0000-4000-8000-000000000001";
|
||||
const giantName = "🦀\\\"".repeat(100_000);
|
||||
const rows = await server.db.insert(executionWorkspaces).values([
|
||||
{ id: giantId, companyId: f.companyId, name: giantName, mode: "shared_workspace", strategyType: "task_directory" },
|
||||
...Array.from({ length: 60 }, () => ({ companyId: f.companyId, name: "🦀\\\"".repeat(4_000), mode: "shared_workspace", strategyType: "task_directory" })),
|
||||
]).returning({ id: executionWorkspaces.id });
|
||||
type Page = { workspaces: Array<{ id: string; name: string; truncatedFields?: string[] }>; hasMore: boolean; nextCursor: string | null };
|
||||
const list = (cursor?: string) => f.authority.execute({ tool: "list_workspaces", arguments: { limit: 100, ...(cursor ? { cursor } : {}) }, callId: randomUUID() }) as Promise<Page>;
|
||||
let page = await list();
|
||||
expect(page).toMatchObject({ nextCursor: giantId, workspaces: [{ id: giantId, truncatedFields: ["name"] }] });
|
||||
const seen: string[] = [];
|
||||
for (;;) {
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThanOrEqual(128 * 1024);
|
||||
seen.push(...page.workspaces.map(row => row.id));
|
||||
if (!page.nextCursor) break;
|
||||
const cursor = page.nextCursor;
|
||||
page = await list(cursor);
|
||||
expect(page.nextCursor === null || page.nextCursor > cursor).toBe(true);
|
||||
expect(page.workspaces.every(row => row.name === "🦀\\\"".repeat(4_000))).toBe(true);
|
||||
}
|
||||
expect(seen).toEqual(rows.map(row => row.id).sort());
|
||||
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.userId);
|
||||
const detail = await fetch(`${server.apiUrl}/api/execution-workspaces/${giantId}`, { headers: { authorization: `Bearer ${token}` } });
|
||||
expect(detail.status).toBe(200);
|
||||
expect((await detail.json()).name).toBe(giantName);
|
||||
});
|
||||
it("keeps source authorization on projectless task workspace inspection, selection, and preparation", async () => {
|
||||
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
|
||||
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, f.issueId));
|
||||
|
||||
@@ -4,11 +4,43 @@ import { callTaskWorkspaceTool } from "../services/task-workspace-tools.js";
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
const base = { apiUrl: "https://paperclip.example/api", token: "run-token", companyId: "company", issueId: "task" };
|
||||
describe("task workspace semantic transport", () => {
|
||||
it("requests a bounded workspace page instead of returning the entire inventory", async () => {
|
||||
const inventory = Array.from({ length: 8_000 }, (_, i) => ({ id: `workspace-${i}`, name: `Workspace ${i}`, cwd: `/tmp/${"repository/".repeat(20)}${i}` }));
|
||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||
const requestedLimit = new URL(url).searchParams.get("limit");
|
||||
return Response.json(requestedLimit ? inventory.slice(0, Number(requestedLimit)) : inventory);
|
||||
}));
|
||||
const page = await callTaskWorkspaceTool({ ...base, name: "list_workspaces", arguments: {} });
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThan(128 * 1024);
|
||||
expect(page).toMatchObject({ workspaces: inventory.slice(0, 50), returnedCount: 50, limit: 50,
|
||||
hasMore: true, nextCursor: "workspace-49" });
|
||||
});
|
||||
it("discovers task-selectable workspaces instead of legacy isolated reuse candidates", async () => {
|
||||
const fetcher = vi.fn(async () => Response.json([{ id: "shared", mode: "shared_workspace" }]));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
await expect(callTaskWorkspaceTool({ ...base, name: "list_workspaces", arguments: {} })).resolves.toEqual([{ id: "shared", mode: "shared_workspace" }]);
|
||||
expect(fetcher.mock.calls[0]).toEqual(["https://paperclip.example/api/companies/company/execution-workspaces?summary=true&selectableForTask=true", expect.objectContaining({ method: "GET" })]);
|
||||
await expect(callTaskWorkspaceTool({ ...base, name: "list_workspaces", arguments: {} })).resolves.toEqual({
|
||||
workspaces: [{ id: "shared", mode: "shared_workspace" }], limit: 50, returnedCount: 1, hasMore: false, nextCursor: null,
|
||||
});
|
||||
expect(fetcher.mock.calls[0]).toEqual(["https://paperclip.example/api/companies/company/execution-workspaces?summary=true&selectableForTask=true&limit=51", expect.objectContaining({ method: "GET" })]);
|
||||
});
|
||||
it("fits large summary fields in a byte-bounded page with a usable cursor", async () => {
|
||||
const rows = Array.from({ length: 51 }, (_, i) => ({ id: `workspace-${i}`, name: "🦀\\\"".repeat(4_000) }));
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json(rows)));
|
||||
const page = await callTaskWorkspaceTool({ ...base, name: "list_workspaces", arguments: {} });
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThanOrEqual(128 * 1024);
|
||||
expect(page.workspaces.length).toBeGreaterThan(0);
|
||||
expect(page.workspaces.length).toBeLessThan(50);
|
||||
expect(page.nextCursor).toBe(page.workspaces.at(-1).id);
|
||||
expect(page.hasMore).toBe(true);
|
||||
expect(page.workspaces.every((row: unknown, i: number) => JSON.stringify(row) === JSON.stringify(rows[i]))).toBe(true);
|
||||
});
|
||||
it("returns an explicit preview and advances past an individually oversized summary", async () => {
|
||||
const row = { id: "workspace-one", name: "🦀\\\"".repeat(100_000), cwd: "/tmp/source" };
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json([row, { id: "workspace-two", name: "Next" }])));
|
||||
const page = await callTaskWorkspaceTool({ ...base, name: "list_workspaces", arguments: {} });
|
||||
expect(Buffer.byteLength(JSON.stringify(page))).toBeLessThanOrEqual(128 * 1024);
|
||||
expect(page).toMatchObject({ returnedCount: 1, hasMore: true, nextCursor: row.id,
|
||||
workspaces: [{ id: row.id, cwd: row.cwd, truncatedFields: ["name"] }] });
|
||||
});
|
||||
it("uses the normal bound task routes without passing caller identities or interrupt requests", async () => {
|
||||
const fetcher = vi.fn(async () => Response.json({ kind: "scheduled", applies: "next_normal_admission" }));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
reconcileExecutionWorkspaceBranchSchema,
|
||||
updateExecutionWorkspaceSchema,
|
||||
workspaceOverviewQuerySchema,
|
||||
executionWorkspaceSummaryPageQuerySchema,
|
||||
workspaceRuntimeControlTargetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
|
||||
@@ -142,6 +143,15 @@ export function executionWorkspaceRoutes(db: Db, opts: { pluginWorkerManager?: P
|
||||
reuseEligible: req.query.reuseEligible === "true",
|
||||
selectableForTask: req.query.selectableForTask === "true",
|
||||
};
|
||||
if (req.query.summary === "true" && (req.query.limit !== undefined || req.query.cursor !== undefined)) {
|
||||
const pagination = executionWorkspaceSummaryPageQuerySchema.safeParse({ limit: req.query.limit, cursor: req.query.cursor });
|
||||
if (!pagination.success) {
|
||||
res.status(422).json({ error: "Invalid workspace summary pagination", details: pagination.error.flatten() });
|
||||
return;
|
||||
}
|
||||
res.json(await svc.listSummaries(companyId, filters, pagination.data));
|
||||
return;
|
||||
}
|
||||
const workspaces = req.query.summary === "true"
|
||||
? await svc.listSummaries(companyId, filters)
|
||||
: await svc.list(companyId, filters);
|
||||
|
||||
@@ -49,7 +49,7 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual, promisify } from "node:util";
|
||||
import { and, asc, desc, eq, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gt, inArray, isNull, lte, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
executionWorkspaces,
|
||||
@@ -2927,9 +2927,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
reuseEligible?: boolean;
|
||||
selectableForTask?: boolean;
|
||||
readCondition?: SQL<boolean>;
|
||||
}) => {
|
||||
}, pagination?: { limit: number; cursor?: string }) => {
|
||||
const conditions = buildListConditions(companyId, filters);
|
||||
const rows = await db
|
||||
if (pagination?.cursor) conditions.push(gt(executionWorkspaces.id, pagination.cursor));
|
||||
const query = db
|
||||
.select({
|
||||
id: executionWorkspaces.id,
|
||||
name: executionWorkspaces.name,
|
||||
@@ -2941,8 +2942,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
|
||||
lastUsedAt: executionWorkspaces.lastUsedAt,
|
||||
})
|
||||
.from(executionWorkspaces)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt));
|
||||
.where(and(...conditions));
|
||||
const rows = await (pagination
|
||||
? query.orderBy(asc(executionWorkspaces.id)).limit(pagination.limit)
|
||||
: query.orderBy(desc(executionWorkspaces.lastUsedAt), desc(executionWorkspaces.createdAt)));
|
||||
return rows.map((row) => toExecutionWorkspaceSummary(row));
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, companies, executionWorkspaces, heartbeatRunEvents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import { expect, it } from "vitest";
|
||||
import { startRunnerApiTestServer } from "../../__tests__/helpers/runner-api-server.js";
|
||||
import { createIsolatedTestHome } from "../../__tests__/helpers/isolated-test-home.js";
|
||||
import { heartbeatService } from "../heartbeat.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { closeIdleWarmNativeSessionsForRestart } from "./native-session-executor.js";
|
||||
|
||||
it.skipIf(process.env.PAPERCLIP_LIVE_WORKSPACE_DISCOVERY !== "true")("settles workspace discovery and resumes a second real Codex turn", async () => {
|
||||
const home = createIsolatedTestHome();
|
||||
const keys = ["PAPERCLIP_HOME", "CODEX_HOME", "PAPERCLIP_API_URL", "PAPERCLIP_AGENT_JWT_SECRET", "PAPERCLIP_TELEMETRY_DISABLED"];
|
||||
const saved = Object.fromEntries(keys.map(key => [key, process.env[key]]));
|
||||
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>> | undefined;
|
||||
let heartbeat: ReturnType<typeof heartbeatService> | undefined;
|
||||
try {
|
||||
const codexHome = join(home.home, "codex");
|
||||
await mkdir(codexHome, { mode: 0o700 });
|
||||
await copyFile(join(process.env.PAPERCLIP_LIVE_CODEX_HOME ?? join(homedir(), ".codex"), "auth.json"), join(codexHome, "auth.json"));
|
||||
process.env.CODEX_HOME = codexHome;
|
||||
process.env.PAPERCLIP_HOME = join(home.home, "instance");
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
|
||||
process.env.PAPERCLIP_TELEMETRY_DISABLED = "1";
|
||||
server = await startRunnerApiTestServer({ deploymentMode: "local_trusted" });
|
||||
process.env.PAPERCLIP_API_URL = server.apiUrl;
|
||||
const f = await server.fixture({ connectionScenario: "fresh" });
|
||||
const db = server.db;
|
||||
await db.insert(executionWorkspaces).values(Array.from({ length: 2_000 }, (_, index) => ({ companyId: f.companyId,
|
||||
name: `Workspace ${index}`, mode: "shared_workspace", strategyType: "task_directory", cwd: `/tmp/${"repository/".repeat(20)}${index}` })));
|
||||
await db.update(issues).set({ executionRunId: null, executionWorkspaceSettings: { mode: "shared_workspace" },
|
||||
title: "Verify paged workspace discovery", description: "Follow each latest comment. Use list_workspaces twice: request limit 2, then request limit 2 with its nextCursor. Confirm both pages have two distinct workspace IDs and hasMore true. Follow the comment's file step and finish this step with paperclip_finish, reportedWorkDisposition done. No external services or public writes are needed.",
|
||||
}).where(eq(issues.id, f.issueId));
|
||||
await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, f.runId));
|
||||
await db.update(companies).set({ defaultResponsibleUserId: f.userId }).where(eq(companies.id, f.companyId));
|
||||
await db.update(agents).set({ adapterConfig: { provider: "codex", model: "gpt-6.1-sol", modelReasoningEffort: "high",
|
||||
cwd: f.workspace, workspaceStrategy: { type: "project_primary" }, env: { CODEX_HOME: codexHome }, timeoutSec: 180, lifecycleMode: "per_turn" },
|
||||
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 1 } },
|
||||
}).where(eq(agents.id, f.agentId));
|
||||
await instanceSettingsService(db).updateExperimental({ enableNativeRunner: true, enableIsolatedWorkspaces: true });
|
||||
heartbeat = heartbeatService(db);
|
||||
const service = issueService(db), markers = [randomUUID(), randomUUID()];
|
||||
const completed: Array<{ runId: string; sessionId: string | null; providerSessionId: string | null; toolCalls: number }> = [];
|
||||
for (const [index, marker] of markers.entries()) {
|
||||
await service.update(f.issueId, { status: "todo", actorUserId: f.userId! });
|
||||
const comment = await service.addComment(f.issueId, index === 0
|
||||
? `After verifying the two workspace pages, write exactly ${marker} and a newline to discovery.txt. Read it and finish.`
|
||||
: `After verifying the two workspace pages, append exactly ${marker} and a newline once to discovery.txt. Preserve and read its existing line, then finish.`, { userId: f.userId! });
|
||||
const run = await heartbeat.wakeup(f.agentId, { source: "automation", triggerDetail: "system", reason: "issue_reopened_via_comment",
|
||||
requestedByActorType: "user", requestedByActorId: f.userId,
|
||||
payload: { issueId: f.issueId, commentId: comment.id, mutation: "comment", reopenedFrom: "done" },
|
||||
contextSnapshot: { issueId: f.issueId, taskId: f.issueId, wakeCommentId: comment.id, skipIssueComment: true },
|
||||
});
|
||||
expect(run?.id).toBeTruthy();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const after = await heartbeat.getRun(run!.id);
|
||||
expect({ status: after?.status, errorCode: after?.errorCode, error: after?.error }).toMatchObject({ status: "succeeded", errorCode: null });
|
||||
expect(after?.resultJson?.recoveredExecutionFailure).toBeUndefined();
|
||||
const events = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, run!.id)).orderBy(heartbeatRunEvents.seq);
|
||||
type ToolItem = { id: string; type: string; name?: string; input?: { cursor?: string; limit?: number };
|
||||
is_error?: boolean; error?: unknown; result?: { workspaces: Array<{ id: string }>; returnedCount: number; hasMore: boolean; nextCursor: string | null } };
|
||||
const items = events.flatMap(event => {
|
||||
const item = (event.payload as { prpEvent?: { payload?: { item?: ToolItem } } } | null)?.prpEvent?.payload?.item;
|
||||
return item ? [item] : [];
|
||||
});
|
||||
const calls = items.filter(item => item.type === "tool_use" && item.name === "list_workspaces");
|
||||
const receipts = new Map(items.filter(item => item.type === "tool_result").map(item => [item.id, item]));
|
||||
for (const call of calls) {
|
||||
expect(receipts.get(call.id)).toMatchObject({ result: { hasMore: true } });
|
||||
expect(receipts.get(call.id)?.is_error).not.toBe(true);
|
||||
expect(receipts.get(call.id)?.error).toBeUndefined();
|
||||
expect(Buffer.byteLength(JSON.stringify(receipts.get(call.id)?.result))).toBeLessThanOrEqual(128 * 1024);
|
||||
}
|
||||
const firstIndex = calls.findIndex(call => call.input?.limit === 2 && !call.input.cursor);
|
||||
const first = calls[firstIndex];
|
||||
expect(first).toBeDefined();
|
||||
const firstPage = receipts.get(first!.id)!.result!;
|
||||
expect(firstPage).toMatchObject({ returnedCount: 2, hasMore: true });
|
||||
expect(firstPage.workspaces).toHaveLength(2);
|
||||
expect(firstPage.nextCursor).toBe(firstPage.workspaces.at(-1)!.id);
|
||||
const second = calls.slice(firstIndex + 1).find(call => call.input?.limit === 2 && call.input.cursor === firstPage.nextCursor);
|
||||
expect(second).toBeDefined();
|
||||
const secondPage = receipts.get(second!.id)!.result!;
|
||||
expect(secondPage).toMatchObject({ returnedCount: 2, hasMore: true });
|
||||
expect(secondPage.workspaces).toHaveLength(2);
|
||||
expect(new Set([...firstPage.workspaces, ...secondPage.workspaces].map(row => row.id)).size).toBe(4);
|
||||
expect(await readFile(join(f.workspace, "discovery.txt"), "utf8")).toBe(markers.slice(0, index + 1).join("\n") + "\n");
|
||||
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
|
||||
expect(task).toMatchObject({ status: "done", executionRunId: null, checkoutRunId: null });
|
||||
completed.push({ runId: run!.id, sessionId: after!.nativeSessionId, providerSessionId: after!.sessionIdAfter, toolCalls: calls.length });
|
||||
}
|
||||
expect(completed[1].providerSessionId).toBe(completed[0].providerSessionId);
|
||||
const evidence = { passed: true, model: "gpt-6.1-sol", effort: "high", lifecycleMode: "per_turn",
|
||||
platform: `${process.platform}-${process.arch}`, inventory: 2_000, completed, fileLines: 2, taskStatus: "done", locksCleared: true,
|
||||
cursorFollowed: true, nonOverlappingPages: true };
|
||||
if (process.env.PAPERCLIP_LIVE_EVIDENCE_PATH) await writeFile(process.env.PAPERCLIP_LIVE_EVIDENCE_PATH, JSON.stringify(evidence, null, 2) + "\n");
|
||||
console.log(JSON.stringify(evidence));
|
||||
} finally {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
await closeIdleWarmNativeSessionsForRestart();
|
||||
await server?.close();
|
||||
for (const key of keys) { if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key]; }
|
||||
home.cleanup();
|
||||
}
|
||||
}, 600_000);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared";
|
||||
import { listTaskWorkspacesSchema, prepareWorkspaceRepositorySchema, selectTaskWorkspaceSchema } from "@paperclipai/shared";
|
||||
import { badRequest } from "../errors.js";
|
||||
import { executionWorkspaceRepositories, type Db } from "@paperclipai/db";
|
||||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
@@ -31,13 +31,45 @@ function repositoryView(repo: Record<string, unknown>) {
|
||||
state: repo.state, pinnedCommit: repo.pinnedCommit, branchName: repo.branchName };
|
||||
}
|
||||
|
||||
function workspacePage(rows: Array<Record<string, unknown>>, limit: number) {
|
||||
const workspaces: Array<Record<string, unknown>> = [];
|
||||
const result = () => ({ workspaces, limit, returnedCount: workspaces.length,
|
||||
hasMore: rows.length > workspaces.length,
|
||||
nextCursor: rows.length > workspaces.length ? workspaces.at(-1)!.id : null });
|
||||
for (const row of rows.slice(0, limit)) {
|
||||
workspaces.push(row);
|
||||
if (Buffer.byteLength(JSON.stringify(result())) <= 128 * 1024) continue;
|
||||
workspaces.pop();
|
||||
if (workspaces.length === 0) {
|
||||
const preview = { ...row };
|
||||
const truncatedFields: string[] = [];
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (typeof value !== "string") continue;
|
||||
const text = Array.from(value.slice(0, 2048)).slice(0, 1024).join("");
|
||||
if (text === value) continue;
|
||||
preview[key] = text;
|
||||
truncatedFields.push(key);
|
||||
}
|
||||
workspaces.push({ ...preview, truncatedFields });
|
||||
}
|
||||
break;
|
||||
}
|
||||
return result();
|
||||
}
|
||||
|
||||
export async function callTaskWorkspaceTool(input: { name: string; arguments: Record<string, unknown>; apiUrl: string; token: string; companyId: string; issueId: string; workspaceRoot?: string }) {
|
||||
let route = `/issues/${input.issueId}/workspace`;
|
||||
let method = "GET";
|
||||
let body: unknown;
|
||||
const page = input.name === "list_workspaces" ? listTaskWorkspacesSchema.parse(input.arguments) : null;
|
||||
switch (input.name) {
|
||||
case "get_workspace": break;
|
||||
case "list_workspaces": route = `/companies/${input.companyId}/execution-workspaces?summary=true&selectableForTask=true`; break;
|
||||
case "list_workspaces": {
|
||||
const query = new URLSearchParams({ summary: "true", selectableForTask: "true", limit: String(page!.limit + 1) });
|
||||
if (page!.cursor) query.set("cursor", page!.cursor);
|
||||
route = `/companies/${input.companyId}/execution-workspaces?${query}`;
|
||||
break;
|
||||
}
|
||||
case "select_workspace": method = "PUT"; body = selectTaskWorkspaceSchema.parse(input.arguments); break;
|
||||
case "prepare_repository": method = "POST"; route += "/repositories"; body = prepareWorkspaceRepositorySchema.parse(input.arguments); break;
|
||||
default: throw badRequest("Unknown workspace tool");
|
||||
@@ -45,6 +77,9 @@ export async function callTaskWorkspaceTool(input: { name: string; arguments: Re
|
||||
const response = await fetch(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api${route}`, { method, headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(60_000) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Workspace tool failed (${response.status})`);
|
||||
if (page) {
|
||||
return workspacePage(result, page.limit);
|
||||
}
|
||||
if (input.name === "prepare_repository") {
|
||||
const { operationId, ...rest } = result;
|
||||
return { ...rest, ...(result.repository ? { repository: repositoryView(result.repository) } : {}), preparationId: operationId,
|
||||
|
||||
Reference in new issue
Block a user