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:
DottaandPaperclip authored and GitHub committed 2026-11-05 13:24:52 -06:00
1 parent a6f786479e
commit 55389554c2
14 files changed
+378 -28

No files matched your search

+14
View File
@@ -377,6 +377,20 @@ This is why `in_progress` can be strict for agents without forcing the same runt
## 5. Checkout and Active Execution
Native `list_workspaces` discovery returns a page with `workspaces`, `limit`,
`returnedCount`, `hasMore` and `nextCursor`. The default page holds 50 summaries;
the maximum is 100. Serialized pages also fit within 128 KiB. A page can contain
fewer rows when summaries are large. An individually oversized summary returns
1,024-character string previews and names the affected fields in `truncatedFields`;
its full record remains available through the authorized workspace detail API.
Callers follow `nextCursor` until null to enumerate an
inventory. Pages use ascending workspace IDs, and apply current company,
visibility and task-selection conditions before the database limit. Concurrent
changes require a fresh scan. The summary API accepts opt-in `limit` and `cursor`
parameters; existing unpaged collection callers keep their array response and
recent-use order. Pagination prevents a large inventory from exceeding the Runner
command journal limit and leaving an unsettled tool result at session cleanup.
Checkout is the bridge from issue ownership to active agent execution.
- checkout is required to move an issue into agent-owned `in_progress`
@@ -1 +1 @@
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answeLine truncated
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answeLine truncated
@@ -2839,21 +2839,67 @@
"planning",
"skill_test"
],
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"description": "List authorized reusable workspaces in ascending workspace-ID order. Returns workspaces, limit, returnedCount, hasMore and nextCursor. Follow nextCursor until null before treating the inventory as complete. Pages fit 128 KiB and may contain fewer than limit rows. An individually oversized summary includes truncatedFields and string previews; read its full record from GET /api/execution-workspaces/{id} under current access. Each page checks current access; concurrent changes require a fresh scan. Choosing a workspace does not assign a project.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"properties": {
"cursor": {
"description": "The nextCursor from the previous page. Omit for the first page.",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"type": "string"
},
"limit": {
"default": 50,
"maximum": 100,
"minimum": 1,
"type": "integer"
}
},
"required": [],
"type": "object"
},
"operationId": "list_workspaces",
"outputSchema": {
"items": {
"additionalProperties": true,
"type": "object"
"additionalProperties": false,
"properties": {
"hasMore": {
"type": "boolean"
},
"limit": {
"maximum": 100,
"minimum": 1,
"type": "integer"
},
"nextCursor": {
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
"type": [
"string",
"null"
]
},
"returnedCount": {
"maximum": 100,
"minimum": 0,
"type": "integer"
},
"workspaces": {
"items": {
"additionalProperties": true,
"type": "object"
},
"maxItems": 100,
"type": "array"
}
},
"type": "array"
"required": [
"workspaces",
"limit",
"returnedCount",
"hasMore",
"nextCursor"
],
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
@@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:ea55aec60ac7737359115227cc0a5e9000bf0098d1e3f92d4281661cac290868",
"catalogSha256": "sha256:a6c5d6b9cc2895ada81c7756a9aec47002d39740e0296d7a3291fba8242ae9b4",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"
@@ -185,7 +185,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "5ce8623132b60eb3e38214ebb75b98172c8a5cf177791a7a43ef75fe66c89db5",
"sha256": "ae2ff79cbf731bc3d347ec152b5c06f95aa7be5ff33bc6427f5bda7c30ce4f19",
"expectation": "accept",
"compatibilityCase": "canonical"
},
@@ -25,7 +25,7 @@ export const listWorkspacesAction = {
},
"documentation": {
"title": "List available workspaces",
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"description": "List authorized reusable workspaces in ascending workspace-ID order. Returns workspaces, limit, returnedCount, hasMore and nextCursor. Follow nextCursor until null before treating the inventory as complete. Pages fit 128 KiB and may contain fewer than limit rows. An individually oversized summary includes truncatedFields and string previews; read its full record from GET /api/execution-workspaces/{id} under current access. Each page checks current access; concurrent changes require a fresh scan. Choosing a workspace does not assign a project.",
"note": null
},
"examples": {
@@ -36,7 +36,7 @@ export const listWorkspacesAction = {
"success": {
"ok": true,
"operationId": "list_workspaces",
"result": []
"result": { "workspaces": [], "limit": 50, "returnedCount": 0, "hasMore": false, "nextCursor": null }
}
},
"live": {
@@ -46,7 +46,7 @@ export const listWorkspacesAction = {
"operationId": "list_workspaces",
"version": 1,
"title": "List available workspaces",
"description": "List authorized reusable workspaces. A project is optional; choosing a workspace does not assign a project.",
"description": "List authorized reusable workspaces in ascending workspace-ID order. Returns workspaces, limit, returnedCount, hasMore and nextCursor. Follow nextCursor until null before treating the inventory as complete. Pages fit 128 KiB and may contain fewer than limit rows. An individually oversized summary includes truncatedFields and string previews; read its full record from GET /api/execution-workspaces/{id} under current access. Each page checks current access; concurrent changes require a fresh scan. Choosing a workspace does not assign a project.",
"effect": "read",
"requiredClaims": [],
"allowedModes": [
@@ -57,16 +57,24 @@ export const listWorkspacesAction = {
],
"inputSchema": {
"type": "object",
"properties": {},
"properties": {
"limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 },
"cursor": { "type": "string", "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", "description": "The nextCursor from the previous page. Omit for the first page." }
},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
"type": "object",
"properties": {
"workspaces": { "type": "array", "maxItems": 100, "items": { "type": "object", "additionalProperties": true } },
"limit": { "type": "integer", "minimum": 1, "maximum": 100 },
"returnedCount": { "type": "integer", "minimum": 0, "maximum": 100 },
"hasMore": { "type": "boolean" },
"nextCursor": { "type": ["string", "null"], "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }
},
"required": ["workspaces", "limit", "returnedCount", "hasMore", "nextCursor"],
"additionalProperties": false
},
"exposure": "optional"
}
@@ -1,4 +1,14 @@
import { z } from "zod";
export const listTaskWorkspacesSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.string().uuid().optional(),
}).strict();
export const executionWorkspaceSummaryPageQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(101).default(51),
cursor: z.string().uuid().optional(),
}).strict();
export const taskWorkspaceSelectionSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("task_directory") }).strict(),
z.object({ kind: z.literal("existing"), workspaceId: z.string().uuid() }).strict(),
@@ -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" }));
+10
View File
@@ -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);
+8 -5
View File
@@ -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);
+37 -2
View File
@@ -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,