diff --git a/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md b/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md new file mode 100644 index 00000000..1fae60e3 --- /dev/null +++ b/.agents/skills/paperclip-dev-workspace-run-verify-fix/SKILL.md @@ -0,0 +1,377 @@ +--- +name: paperclip-dev-workspace-run-verify-fix +description: > + Run, verify, reseed, and repair a Paperclip isolated dev workspace service. + Use when asked to start or fix a Paperclip project/worktree service and prove + that it is managed by the Paperclip runtime, has the full bootstrapped cloned + database, is healthy, accepts normal dev credentials, exposes populated app + data, and is visible as running from both the control-plane and the served + workspace app. +--- + +# Paperclip Dev Workspace Run / Verify / Fix + +This skill is for Paperclip-specific development workspaces whose service is +started through project execution workspace runtime services, typically a +worktree service such as `paperclip-dev`. + +Success means all of these are true: + +- the service was started through the normal managed runtime path, not a + detached workaround +- the worktree database is a full bootstrapped isolated clone of the primary + instance database +- `/api/health` returns `status: ok` and `bootstrapStatus: ready` +- the root page returns `200` and does not show the first-admin setup gate +- the board user can log in with their normal dev credentials +- the app shows populated cloned data, not only a manually copied auth user +- the main control plane shows the service as `running` / `healthy` with the + expected URL +- the served workspace app also knows about that service and shows it as + `running` / `healthy` + +If any item fails, keep fixing. Do not mark the issue done because one probe +passed. + +## Hard rules + +- Read `doc/DEVELOPING.md` before running Paperclip CLI, dev server, worktree, + database, build, or test commands. +- Use the Paperclip CLI and API as the source of truth for worktree and + database operations. Do not use `psql`, raw embedded-Postgres commands, or + ad hoc row copying for the normal fix path. +- Do not manually copy only auth rows as the final fix. That proves login, but + it does not prove the isolated workspace has the full bootstrapped database. +- Prefer managed runtime start/stop routes over `pnpm dev` or detached shell + processes when the task is about a reusable workspace service. +- Avoid destructive git or database actions. Preserve user changes in the + worktree. +- Run the smallest verification that proves the repair. Add focused tests only + when code changed. +- Leave an issue comment with root cause, exact fix, verification, and any + commit link. Set a clear final status. + +## Inputs to collect + +Use environment variables when available. Do not print API keys or passwords. + +- `PAPERCLIP_API_URL`: main control-plane API URL +- `PAPERCLIP_API_KEY`: agent API key +- `PAPERCLIP_RUN_ID`: current run id for runtime-service mutations +- `PAPERCLIP_TASK_ID`: current issue id +- `PAPERCLIP_COMPANY_ID`: company id +- `PAPERCLIP_AGENT_ID`: current agent id +- execution workspace id for the worktree service +- runtime workspace command id, usually `service:paperclip-dev` +- expected service URL, for example `http://paperclip-dev:40631` +- dev credential owner, if the user supplied one; never post the password + +If the execution workspace id or service command id is missing, read the issue, +project, or execution-workspace API records first. Do not guess and start an +unmanaged server on a random port. + +## Normal run sequence + +Use this when the user says to start the workspace, start it again, or fix a +workspace that should be freshly ready. + +1. Confirm the latest issue comment and restate the success condition in your + own words. +2. Inspect current runtime state from the main control plane. +3. Stop any managed instance of the target runtime service. +4. Reseed the worktree with a full clone from the primary instance when there + is any doubt about database completeness. +5. Start the runtime service through the managed runtime API. +6. Verify health, bootstrap state, login readiness, populated data, and runtime + visibility from both the main control plane and served workspace app. +7. If a verification item fails, diagnose that exact failure and loop back to + the narrowest repair step. +8. Comment on the issue and set the final disposition. + +## Managed start / stop + +Use the runtime-service endpoints on the main control plane. Include +`X-Paperclip-Run-Id` so the mutation is associated with the current heartbeat. + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/stop" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary '{"workspaceCommandId":"service:paperclip-dev"}' +``` + +```sh +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID/runtime-services/start" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary '{"workspaceCommandId":"service:paperclip-dev"}' +``` + +If the API returns an existing service, treat that as a candidate only. Verify +its real `/api/health` before trusting it. + +## Full database reseed + +Use a full reseed when the app says setup is incomplete, login works but data +is missing, the cloned app does not have the expected companies/issues/agents, +or the user explicitly asks for the normal isolated-workspace database. + +```sh +pnpm paperclipai worktree reseed --from-instance default --seed-mode full --yes +``` + +After reseed, restart through the managed runtime path. A reseed can copy +runtime-service rows whose ids no longer match the local process registry, so +runtime adoption and reconciliation must be verified after the start. + +Do not consider the reseed complete until the served app has both auth and +populated product data. A one-off inserted user/account row is a diagnostic +clue, not the final state. + +## Core verification probes + +Set `SERVICE_URL` to the service URL returned by the runtime API. + +```sh +curl -sS "$SERVICE_URL/api/health" | jq +curl -sS -I "$SERVICE_URL/" | head +``` + +Expected health: + +- `status: "ok"` +- `bootstrapStatus: "ready"` +- `bootstrapInviteActive: false` + +Failures to reject: + +- `bootstrap_pending`: the instance will show the first-admin setup gate +- `database_unreachable`: a web process is listening but its database is dead +- a root `200` with unhealthy `/api/health`: stale process adoption bug or a + dead embedded database behind a live Node process + +Check the port owner when a process is already listening: + +```sh +lsof -nP -iTCP:"$SERVICE_PORT" -sTCP:LISTEN || true +``` + +Use this only to identify and remove a stale matching Paperclip dev-runner +process after managed stop fails. Do not kill unrelated processes. + +## Verify main control-plane runtime state + +Read the execution workspace from the main API and inspect the runtime service +record. + +```sh +curl -sS \ + "$PAPERCLIP_API_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq +``` + +The target service should show: + +- matching workspace command id, usually `service:paperclip-dev` +- `status: "running"` +- healthy health fields, if present +- the same URL you are probing +- a local provider reference that maps to the running port owner + +If the main app says running but `/api/health` is bad, stop and replace the +stale process through the managed runtime. + +## Verify served workspace runtime state + +The cloned Paperclip app must also know about the service. Query the same +execution workspace through the served app when agent auth is available there: + +```sh +curl -sS \ + "$SERVICE_URL/api/execution-workspaces/$EXECUTION_WORKSPACE_ID" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq +``` + +The served app should agree that the service is `running` / `healthy` at the +same URL. If the main control plane and served app disagree after a reseed, +the cloned database may contain copied runtime-service ids that do not match +the local process registry. Use the normal start/adoption path again and verify +both sides. If code changed in this area, add a focused regression test. + +## Verify auth and populated data + +Auth and data checks should use product APIs and browser/QA review, not raw DB +queries. + +Minimum API checks: + +```sh +curl -sS "$SERVICE_URL/api/health" | jq '.status, .bootstrapStatus' +curl -sS "$SERVICE_URL/api/companies" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq +curl -sS "$SERVICE_URL/api/agents/me" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" | jq +``` + +Then verify at least one expected cloned product record through the API, such +as a known project, issue key, company, or execution workspace that should +exist in the primary instance. Pick a record relevant to the current issue +rather than a random table count. + +Browser or QA check: + +- open the service URL +- confirm the first-admin setup gate is gone +- sign in with normal dev credentials +- confirm the board loads populated companies/projects/issues/runs +- confirm the workspace service appears with the running URL + +If this environment cannot launch a browser, ask QA to do the visual/login +check and still complete all API checks you can run. Report that browser +verification was delegated and why. + +## Common failures and fixes + +### Setup gate appears + +Symptom: the page says no admin has claimed the instance. + +Fix: run a full worktree reseed from the primary instance, then restart the +managed service. Claiming a first admin can clear the gate, but if the user +asked for the normal isolated workspace database, full reseed is the correct +fix. + +Verify: `/api/health` has `bootstrapStatus: ready`, login works, and populated +data exists. + +### Login fails + +Symptom: bootstrap is ready, but the user's normal dev credentials do not work. + +Likely cause: the isolated DB has roles or bootstrap state but lacks the +primary instance auth users/accounts. + +Fix: full reseed. Do not manually copy only Better Auth user/account rows as +the final fix. + +Verify: user login through browser/QA and `/api/agents/me` with the agent key. + +### Login works but data is missing + +Symptom: user can sign in, but companies/issues/projects/runs are empty or +clearly incomplete. + +Likely cause: a partial auth repair was done instead of a full cloned database. + +Fix: full reseed, managed restart, then verify representative cloned records. + +### Port listens but health says database unreachable + +Symptom: `curl -I /` returns a response, but `/api/health` reports +`database_unreachable`. + +Likely cause: stale Node/web process remained alive after embedded Postgres +died. + +Fix: managed stop first. If the process survives, identify the matching +Paperclip dev-runner process group for the target port and terminate only that +group. Then managed start. + +Verify: `/api/health` is ok after a stability wait and the runtime record is +healthy. + +### Main control plane loses track of a running service + +Symptom: the service URL works, but the main app says the service was not +created or is stopped. + +Likely cause: detached workaround process, stale provider ref, or service +adoption trusted the root URL instead of health. + +Fix: shut down the unmanaged process and restart through the managed runtime. +If code repair is needed, ensure adoption checks `/api/health`, replaces +unhealthy adopted processes, and records the current provider ref. + +Verify: main runtime row and `/api/health` agree. + +### Served app loses track after full reseed + +Symptom: the main app sees `paperclip-dev` running, but the cloned app copied a +runtime-service row whose id does not match the local registry. + +Likely cause: normal DB clone copied persisted runtime rows from the primary +instance into an isolated environment with different local process metadata. + +Fix: use managed start/adoption again. If code repair is needed, adoption +should reconcile by service identity and port, not only by copied row id. + +Verify: main app and served app both show the same service as +`running` / `healthy`. + +### Runtime start returns permission or run-FK errors in the cloned app + +Symptom: starting from the served app returns `403` or a mutation partially +applies before activity logging fails. + +Likely causes: the cloned issue/run/agent state does not match the current +heartbeat, or the run id is absent in the cloned database after reseed. + +Fix: prefer the main control-plane managed runtime path and full reseed. If +the cloned app state itself must be repaired, use normal Paperclip issue/run +transitions first. Do not hide the condition with raw DB edits; report the +exact guard or missing row if it blocks the normal path. + +## When code changes are required + +Make a code change only when the normal operational repair exposes a product +bug. Examples from this failure class: + +- local port owner detection used the wrong `lsof` arguments +- service adoption trusted root `200` instead of `/api/health` +- unhealthy adopted services were not terminated/replaced +- reseeded runtime-service ids were not reconciled with local process registry + +Add focused tests in the affected service test file. For workspace runtime +repairs, the narrow verification is usually: + +```sh +pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts +git diff --check +``` + +Commit logical code changes and link the commit in the issue comment. If no +tracked code changed, say so explicitly. + +## Final issue comment template + +Use concrete evidence, not a vague "it works". + +```md +Fixed and verified the workspace service. + +Root cause: +- + +Fix: +- +- + +Verified: +- main control plane shows running/healthy at +- served workspace app shows the same service running/healthy +- /api/health is ok with bootstrapStatus ready +- root page returns 200 and no setup gate +- dev login verified by without posting credentials +- cloned data verified via +- targeted tests: + +Remaining: +- +``` + +Mark the issue `done` only when every success-condition item is satisfied. If +not, mark `blocked` with a named unblock owner and the exact action needed. diff --git a/AGENTS.md b/AGENTS.md index da912635..52bc6c0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ Prefer additive updates. Keep `doc/SPEC.md` and `doc/SPEC-implementation.md` ali When you are creating a plan file in the repository itself, new plan documents belong in `doc/plans/` and should use `YYYY-MM-DD-slug.md` filenames. This does not replace Paperclip issue planning: if a Paperclip issue asks for a plan, update the issue `plan` document per the `paperclip` skill instead of creating a repo markdown file. 6. Attach inspectable generated artifacts. -When your task produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. See `doc/AGENT-ARTIFACTS.md` for `.mp4` and `.webm` examples. +When your task produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. In this repo, prefer the self-contained skill helper at `skills/paperclip/scripts/paperclip-upload-artifact.sh` so the file is available through the Paperclip API, create/update an artifact work product when the file is the deliverable, link the uploaded artifact in the final issue comment, and then set status. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path. See `doc/AGENT-ARTIFACTS.md` for details and `.mp4`/`.webm` examples. ## 6. Database Change Workflow diff --git a/doc/AGENT-ARTIFACTS.md b/doc/AGENT-ARTIFACTS.md index 4cf92f44..502aec8a 100644 --- a/doc/AGENT-ARTIFACTS.md +++ b/doc/AGENT-ARTIFACTS.md @@ -1,9 +1,9 @@ # Agent Artifact Upload Workflow -Generated files that a board user or reviewer should inspect must be attached to -the Paperclip issue before the agent chooses a final disposition. A local -workspace path is not enough, because cloud users and reviewers often cannot -access the agent's disk. +Generated files that a board user or reviewer should inspect as deliverables +must be attached to the Paperclip issue before the agent chooses a final +disposition. A local workspace path is not enough, because cloud users and +reviewers often cannot access the agent's disk. Use the helper bundled with the Paperclip skill from the repo root: @@ -27,9 +27,50 @@ It uploads the file to artifact work product on `POST /api/issues/{issueId}/work-products` by default. The command prints issue-safe markdown links for the final task comment. +## Uploaded Artifacts vs Workspace Files + +Use uploaded artifacts for deliverables: videos, PDFs, screenshots, archives, +reports, rendered HTML, or any file the board should inspect without needing the +agent's checkout. Attachment-backed artifact work products set `type` to +`artifact` and `provider` to `paperclip`, with metadata canonicalized from the +uploaded `attachmentId`. + +Use `workspace_file` metadata only for important files that intentionally remain +in a project or execution workspace, such as source files, committed markdown +plans, or generated files whose meaning depends on the checkout. Workspace-only +references are useful signposts, but they are not durable uploads. + +Expected work product metadata shape: + +```json +{ + "resourceRef": { + "kind": "workspace_file", + "issueId": "", + "workspaceKind": "execution_workspace", + "workspaceId": "", + "relativePath": "doc/plans/example.md", + "line": 1, + "column": 1, + "displayPath": "doc/plans/example.md:1:1" + } +} +``` + +`workspaceKind` is `execution_workspace` or `project_workspace`. `line` and +`column` are optional. `relativePath` must be relative to that workspace root; +do not store host-local absolute paths as workspace references. + +Workspace file links resolve only inside registered Paperclip workspaces. The +default target is the current issue's execution workspace first, then its +project workspace. A link may target another same-company project workspace only +when it carries both that `projectId` and `workspaceId`. Paperclip does not +resolve arbitrary machine-wide filesystem paths, absolute host paths, home +paths, or relative paths that escape the selected workspace. + ## Completion Pattern -When a task produces a user-inspectable file: +When a task produces a user-inspectable deliverable file: 1. Generate and verify the file locally. 2. Upload it with `skills/paperclip/scripts/paperclip-upload-artifact.sh`. @@ -38,9 +79,12 @@ When a task produces a user-inspectable file: 4. Link the printed attachment URL in the final issue comment. 5. Then set the final issue status. -Final comments should name the uploaded artifact, not just the local filesystem -path. Local paths can be included as diagnostic context, but they cannot be the -only access path. +Final comments should name and link the uploaded artifact or work product, not +just the local filesystem path. For workspace-only files, include the work +product title and recorded relative path. Local paths can be included as +diagnostic context, but they cannot be the only access path. Browse/search is a +fallback for recovering workspace files when the issue link or chip is not +available, not the preferred way to deliver files to users. ## Video Examples diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index c3fd84a3..8b79b67f 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -214,9 +214,9 @@ pnpm paperclipai configure --section storage ## Agent Artifact Uploads -When an agent generates a file that a board user or reviewer should inspect, -attach it to the issue before marking the task complete. Do not rely on a local -workspace path as the only access path. +When an agent generates a file that a board user or reviewer should inspect as +a deliverable, attach it to the issue before marking the task complete. Do not +rely on a local workspace path as the only access path. Use the helper bundled with the Paperclip skill from the repo root: @@ -237,6 +237,10 @@ skills/paperclip/scripts/paperclip-upload-artifact.sh out/walkthrough.webm \ The helper uploads the file as an issue attachment, creates an artifact work product by default, and prints markdown links for the final issue comment. See `doc/AGENT-ARTIFACTS.md` for the full completion pattern and direct API shape. +If a file intentionally remains workspace-only, create a work product with +`metadata.resourceRef.kind: "workspace_file"` and include the workspace-relative +path in the final comment. Use browse/search only as the fallback for recovering +that file, not as the main completion path for deliverables. ## Default Agent Workspaces diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 3ccc0a13..2c43e572 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -402,6 +402,7 @@ Operational policy: - Inline-safe responses use `Content-Disposition: inline`; unsafe types and explicit download requests use `attachment`. - Video attachments are inline-safe and support single `Range: bytes=start-end` requests with `206`, `Content-Range`, and `Accept-Ranges: bytes` for browser playback/seeking. - Attachment-backed artifact work products use `type: "artifact"`, `provider: "paperclip"`, and metadata with `attachmentId`, `contentType`, `byteSize`, `contentPath`, `openPath`, `downloadPath`, and optional `originalFilename`. +- Workspace-only file references use work product `metadata.resourceRef` with `kind: "workspace_file"`, `issueId`, `workspaceKind` (`execution_workspace` or `project_workspace`), `workspaceId`, `relativePath`, optional `line`/`column`, and `displayPath`. These references point at files in a workspace; they do not replace attachment-backed artifacts for deliverables that must be inspectable without workspace access. ## 7.15 `documents` + `document_revisions` + `issue_documents` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6468e6e4..74b55c61 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -449,6 +449,19 @@ export type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationStatus, + WorkspaceFileContent, + WorkspaceFileContentEncoding, + WorkspaceFileListDirectoryItem, + WorkspaceFileListFileItem, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, + WorkspaceFilePreviewKind, + WorkspaceFileRef, + WorkspaceFileResourceKind, + WorkspaceFileSelector, + WorkspaceFileWorkspaceKind, + ResolvedWorkspaceResource, WorkspaceRuntimeDesiredState, WorkspaceRealizationRecord, WorkspaceRealizationRequest, @@ -1014,6 +1027,7 @@ export { linkIssueApprovalSchema, createIssueAttachmentMetadataSchema, createIssueWorkProductSchema, + issueWorkProductMetadataSchema, updateIssueWorkProductSchema, attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, @@ -1037,6 +1051,16 @@ export { executionWorkspaceCloseLinkedIssueSchema, executionWorkspaceCloseReadinessSchema, executionWorkspaceCloseReadinessStateSchema, + resolvedWorkspaceResourceSchema, + workspaceFileContentSchema, + workspaceFileListModeSchema, + workspaceFileListQuerySchema, + workspaceFilePreviewKindSchema, + workspaceFileRefSchema, + workspaceFileResourceKindSchema, + workspaceFileResourceQuerySchema, + workspaceFileSelectorSchema, + workspaceFileWorkspaceKindSchema, issueDocumentFormatSchema, issueDocumentKeySchema, upsertIssueDocumentSchema, @@ -1065,6 +1089,8 @@ export { type UpdateIssueWorkProduct, type CompanyArtifactsQuery, type UpdateExecutionWorkspace, + type WorkspaceFileListQuery, + type WorkspaceFileResourceQuery, type IssueDocumentFormat, type UpsertIssueDocument, type RestoreIssueDocumentRevision, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 8bc246b8..fead54e4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -216,6 +216,21 @@ export type { WorkspaceOperationPhase, WorkspaceOperationStatus, } from "./workspace-operation.js"; +export type { + WorkspaceFileContent, + WorkspaceFileContentEncoding, + WorkspaceFileListDirectoryItem, + WorkspaceFileListFileItem, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, + WorkspaceFilePreviewKind, + WorkspaceFileRef, + WorkspaceFileResourceKind, + WorkspaceFileSelector, + WorkspaceFileWorkspaceKind, + ResolvedWorkspaceResource, +} from "./workspace-file-resource.js"; export type { IssueWorkProduct, IssueWorkProductType, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 0bb8009c..0a6c946c 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -31,6 +31,7 @@ export interface InstanceExperimentalSettings { enableIsolatedWorkspaces: boolean; enableStreamlinedLeftNavigation: boolean; enableIssuePlanDecompositions: boolean; + enableExperimentalFileViewer: boolean; enableCloudSync: boolean; autoRestartDevServerWhenIdle: boolean; enableIssueGraphLivenessAutoRecovery: boolean; diff --git a/packages/shared/src/types/workspace-file-resource.ts b/packages/shared/src/types/workspace-file-resource.ts new file mode 100644 index 00000000..8f6aaed4 --- /dev/null +++ b/packages/shared/src/types/workspace-file-resource.ts @@ -0,0 +1,119 @@ +export type WorkspaceFileWorkspaceKind = "execution_workspace" | "project_workspace"; +export type WorkspaceFileSelector = "auto" | "execution" | "project"; +export type WorkspaceFileListMode = "all" | "recent" | "changed"; +export type WorkspaceFilePreviewKind = "text" | "image" | "video" | "pdf" | "unsupported"; +export type WorkspaceFileResourceKind = "file" | "directory" | "remote_resource"; +export type WorkspaceFileContentEncoding = "utf8" | "base64"; + +export interface WorkspaceFileRef { + kind: "workspace_file"; + issueId?: string; + projectId?: string; + projectName?: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + relativePath: string; + line?: number | null; + column?: number | null; + displayPath: string; +} + +export interface ResolvedWorkspaceResource { + kind: WorkspaceFileResourceKind; + provider: "local_fs" | "git_worktree" | "remote_managed" | string; + title: string; + displayPath: string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + projectId?: string | null; + projectName?: string | null; + contentType?: string | null; + byteSize?: number | null; + previewKind: WorkspaceFilePreviewKind; + denialReason?: string | null; + capabilities: { + preview: boolean; + download: false; + listChildren: boolean; + }; +} + +export interface WorkspaceFileContent { + resource: ResolvedWorkspaceResource; + content: { + encoding: WorkspaceFileContentEncoding; + data: string; + }; +} + +export interface WorkspaceFileListFileItem { + kind: "file"; + provider: "local_fs" | "git_worktree" | string; + title: string; + relativePath: string; + displayPath: string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + projectId?: string | null; + projectName?: string | null; + contentType?: string | null; + byteSize?: number | null; + modifiedAt?: string | null; + previewKind: Exclude; + capabilities: { + preview: true; + download: false; + listChildren: false; + }; +} + +export interface WorkspaceFileListDirectoryItem { + kind: "directory"; + provider: "local_fs" | "git_worktree" | string; + title: string; + relativePath: string; + displayPath: string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + projectId?: string | null; + projectName?: string | null; + contentType: null; + byteSize: null; + modifiedAt?: string | null; + previewKind: "unsupported"; + capabilities: { + preview: false; + download: false; + listChildren: true; + }; +} + +export type WorkspaceFileListItem = WorkspaceFileListFileItem | WorkspaceFileListDirectoryItem; + +export interface WorkspaceFileListResponse { + kind: "workspace_file_list"; + state: "available" | "unavailable"; + unavailableReason?: string | null; + workspace: { + provider: "local_fs" | "git_worktree" | string; + workspaceLabel: string; + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + projectId?: string | null; + projectName?: string | null; + } | null; + query: { + workspace: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + path?: string | null; + q: string | null; + limit: number; + offset: number; + }; + items: WorkspaceFileListItem[]; + scannedCount: number; + truncated: boolean; +} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 1595a680..683950de 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -320,12 +320,14 @@ export { export { createIssueWorkProductSchema, + issueWorkProductMetadataSchema, updateIssueWorkProductSchema, attachmentArtifactWorkProductMetadataSchema, issueWorkProductTypeSchema, issueWorkProductStatusSchema, issueWorkProductReviewStateSchema, type CreateIssueWorkProduct, + type IssueWorkProductMetadata, type UpdateIssueWorkProduct, } from "./work-product.js"; @@ -356,6 +358,21 @@ export { type UpdateExecutionWorkspace, } from "./execution-workspace.js"; +export { + resolvedWorkspaceResourceSchema, + workspaceFileListModeSchema, + workspaceFileListQuerySchema, + workspaceFileContentSchema, + workspaceFilePreviewKindSchema, + workspaceFileRefSchema, + workspaceFileResourceKindSchema, + workspaceFileResourceQuerySchema, + workspaceFileSelectorSchema, + workspaceFileWorkspaceKindSchema, + type WorkspaceFileListQuery, + type WorkspaceFileResourceQuery, +} from "./workspace-file-resource.js"; + export { createGoalSchema, updateGoalSchema, diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 448aa107..e5c235cb 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -40,6 +40,7 @@ export const instanceExperimentalSettingsSchema = z.object({ enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), + enableExperimentalFileViewer: z.boolean().default(false), enableCloudSync: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), diff --git a/packages/shared/src/validators/work-product.ts b/packages/shared/src/validators/work-product.ts index ac31b476..3ed27ba6 100644 --- a/packages/shared/src/validators/work-product.ts +++ b/packages/shared/src/validators/work-product.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { workspaceFileRefSchema } from "./workspace-file-resource.js"; function attachmentContentPath(attachmentId: string): string { return `/api/attachments/${attachmentId}/content`; @@ -68,6 +69,14 @@ export const attachmentArtifactWorkProductMetadataSchema = z.object({ export type AttachmentArtifactWorkProductMetadata = z.infer; +export const issueWorkProductMetadataSchema = z + .object({ + resourceRef: workspaceFileRefSchema.optional().nullable(), + }) + .passthrough(); + +export type IssueWorkProductMetadata = z.infer; + export const createIssueWorkProductSchema = z.object({ projectId: z.string().uuid().optional().nullable(), executionWorkspaceId: z.string().uuid().optional().nullable(), @@ -82,7 +91,7 @@ export const createIssueWorkProductSchema = z.object({ isPrimary: z.boolean().optional().default(false), healthStatus: z.enum(["unknown", "healthy", "unhealthy"]).optional().default("unknown"), summary: z.string().optional().nullable(), - metadata: z.record(z.string(), z.unknown()).optional().nullable(), + metadata: issueWorkProductMetadataSchema.optional().nullable(), createdByRunId: z.string().uuid().optional().nullable(), }); diff --git a/packages/shared/src/validators/workspace-file-resource.ts b/packages/shared/src/validators/workspace-file-resource.ts new file mode 100644 index 00000000..1eea7215 --- /dev/null +++ b/packages/shared/src/validators/workspace-file-resource.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +const workspaceFileListSearchMaxBytes = 128; + +function utf8ByteLength(value: string) { + return new TextEncoder().encode(value).length; +} + +export const workspaceFileWorkspaceKindSchema = z.enum(["execution_workspace", "project_workspace"]); +export const workspaceFileSelectorSchema = z.enum(["auto", "execution", "project"]).default("auto"); +export const workspaceFileListModeSchema = z.enum(["all", "recent", "changed"]).default("all"); +export const workspaceFilePreviewKindSchema = z.enum(["text", "image", "video", "pdf", "unsupported"]); +export const workspaceFileResourceKindSchema = z.enum(["file", "directory", "remote_resource"]); + +export const workspaceFileRefSchema = z.object({ + kind: z.literal("workspace_file"), + issueId: z.string().uuid().optional(), + projectId: z.string().uuid().optional(), + projectName: z.string().min(1).optional(), + workspaceKind: workspaceFileWorkspaceKindSchema, + workspaceId: z.string().uuid(), + relativePath: z.string().min(1), + line: z.number().int().positive().nullable().optional(), + column: z.number().int().positive().nullable().optional(), + displayPath: z.string().min(1), +}); + +export const workspaceFileResourceQuerySchema = z.object({ + projectId: z.string().uuid().optional(), + workspaceId: z.string().uuid().optional(), + path: z + .string() + .min(1) + .refine((value) => !/[\x00-\x1f\x7f]/.test(value), { + message: "Workspace file path contains an invalid character", + params: { code: "invalid_path" }, + }), + workspace: workspaceFileSelectorSchema.optional(), +}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), { + message: "Workspace file target requires both projectId and workspaceId", + path: ["workspaceId"], + params: { code: "invalid_target" }, +}); + +export const workspaceFileListQuerySchema = z.object({ + projectId: z.string().uuid().optional(), + workspaceId: z.string().uuid().optional(), + workspace: workspaceFileSelectorSchema.optional(), + path: z + .string() + .min(1) + .refine((value) => !/[\x00-\x1f\x7f]/.test(value), { + message: "Workspace folder path contains an invalid character", + params: { code: "invalid_path" }, + }) + .optional(), + mode: workspaceFileListModeSchema.optional(), + q: z + .string() + .refine((value) => !/[\x00-\x1f\x7f]/.test(value), { + message: "Workspace file search contains an invalid character", + params: { code: "invalid_query" }, + }) + .refine((value) => utf8ByteLength(value.trim()) <= workspaceFileListSearchMaxBytes, { + message: "Workspace file search is too long", + params: { code: "invalid_query" }, + }) + .optional(), + limit: z.coerce.number().int().min(1).max(100).default(25), + offset: z.coerce.number().int().min(0).max(10_000).default(0), +}).refine((value) => Boolean(value.projectId) === Boolean(value.workspaceId), { + message: "Workspace file target requires both projectId and workspaceId", + path: ["workspaceId"], + params: { code: "invalid_target" }, +}); + +export const resolvedWorkspaceResourceSchema = z.object({ + kind: workspaceFileResourceKindSchema, + provider: z.string().min(1), + title: z.string().min(1), + displayPath: z.string().min(1), + workspaceLabel: z.string().min(1), + workspaceKind: workspaceFileWorkspaceKindSchema, + workspaceId: z.string().uuid(), + projectId: z.string().uuid().nullable().optional(), + projectName: z.string().min(1).nullable().optional(), + contentType: z.string().nullable().optional(), + byteSize: z.number().int().nonnegative().nullable().optional(), + previewKind: workspaceFilePreviewKindSchema, + denialReason: z.string().nullable().optional(), + capabilities: z.object({ + preview: z.boolean(), + download: z.literal(false), + listChildren: z.boolean(), + }), +}); + +export const workspaceFileContentSchema = z.object({ + resource: resolvedWorkspaceResourceSchema, + content: z.object({ + encoding: z.enum(["utf8", "base64"]), + data: z.string(), + }), +}); + +export type WorkspaceFileResourceQuery = z.infer; +export type WorkspaceFileListQuery = z.infer; diff --git a/packages/shared/src/work-product.test.ts b/packages/shared/src/work-product.test.ts new file mode 100644 index 00000000..0fdfb875 --- /dev/null +++ b/packages/shared/src/work-product.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { + issueWorkProductMetadataSchema, + createIssueWorkProductSchema, +} from "./validators/work-product.js"; + +describe("work product metadata with resource ref", () => { + it("accepts valid workspace file ref in metadata", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + line: 42, + column: 10, + displayPath: "src/components/App.tsx:42:10", + }, + }); + + expect(result.resourceRef?.kind).toBe("workspace_file"); + expect(result.resourceRef?.issueId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(result.resourceRef?.relativePath).toBe("src/components/App.tsx"); + }); + + it("accepts workspace file ref without line/column", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "project_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "README.md", + displayPath: "README.md", + }, + }); + + expect(result.resourceRef?.line).toBeUndefined(); + expect(result.resourceRef?.column).toBeUndefined(); + }); + + it("accepts null resourceRef", () => { + const result = issueWorkProductMetadataSchema.parse({ + resourceRef: null, + }); + + expect(result.resourceRef).toBeNull(); + }); + + it("accepts undefined resourceRef", () => { + const result = issueWorkProductMetadataSchema.parse({}); + + expect(result.resourceRef).toBeUndefined(); + }); + + it("rejects invalid resourceRef missing kind", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); + + it("rejects invalid workspaceKind", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "invalid_kind", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); + + it("rejects invalid issueId (not UUID)", () => { + expect(() => { + issueWorkProductMetadataSchema.parse({ + resourceRef: { + kind: "workspace_file", + issueId: "not-a-uuid", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "src/components/App.tsx", + displayPath: "src/components/App.tsx", + }, + }); + }).toThrow(); + }); +}); + +describe("create issue work product with resource ref", () => { + it("accepts work product with resourceRef in metadata", () => { + const result = createIssueWorkProductSchema.parse({ + type: "artifact", + provider: "paperclip", + title: "Build output", + status: "active", + metadata: { + resourceRef: { + kind: "workspace_file", + issueId: "123e4567-e89b-12d3-a456-426614174000", + workspaceKind: "execution_workspace", + workspaceId: "223e4567-e89b-12d3-a456-426614174001", + relativePath: "dist/index.js", + displayPath: "dist/index.js", + }, + }, + }); + + expect((result.metadata as any)?.resourceRef?.kind).toBe("workspace_file"); + }); + + it("accepts work product without resourceRef in metadata", () => { + const result = createIssueWorkProductSchema.parse({ + type: "pull_request", + provider: "github", + title: "Feature PR", + status: "active", + metadata: { + prNumber: 123, + }, + }); + + expect(result.metadata?.resourceRef).toBeUndefined(); + }); +}); diff --git a/scripts/screenshot-file-viewer.mjs b/scripts/screenshot-file-viewer.mjs new file mode 100644 index 00000000..8623eb86 --- /dev/null +++ b/scripts/screenshot-file-viewer.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Screenshots every FileViewerSheet Storybook state at desktop + mobile viewports. + +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const BASE = "http://localhost:6006/iframe.html"; +const OUT_DIR = path.resolve(process.argv[2] || "/tmp/pap-1963"); + +const SHOTS = [ + { id: "components-file-viewer-sheet--text-content-with-highlighted-line", label: "content-highlighted-line" }, + { id: "components-file-viewer-sheet--text-content-long-path-truncation", label: "content-long-path" }, + { id: "components-file-viewer-sheet--open-file-prompt", label: "open-file-prompt" }, + { id: "components-file-viewer-sheet--loading-spinner", label: "loading-spinner" }, + { id: "components-file-viewer-sheet--error-not-found-with-fallback", label: "error-not-found" }, + { id: "components-file-viewer-sheet--error-no-workspace", label: "error-no-workspace" }, + { id: "components-file-viewer-sheet--error-outside-workspace", label: "error-outside-workspace" }, + { id: "components-file-viewer-sheet--error-denied-sensitive", label: "error-denied" }, + { id: "components-file-viewer-sheet--remote-workspace", label: "remote-workspace" }, + { id: "components-file-viewer-sheet--workspace-archived", label: "workspace-archived" }, + { id: "components-file-viewer-sheet--binary-unsupported", label: "binary-unsupported" }, + { id: "components-file-viewer-sheet--too-large-to-preview", label: "too-large" }, +]; + +const VIEWPORTS = [ + { name: "desktop", width: 1440, height: 900 }, + { name: "mobile", width: 390, height: 844 }, +]; + +async function run() { + await fs.mkdir(OUT_DIR, { recursive: true }); + const browser = await chromium.launch({ headless: true }); + try { + for (const vp of VIEWPORTS) { + const context = await browser.newContext({ + viewport: { width: vp.width, height: vp.height }, + deviceScaleFactor: 2, + }); + const page = await context.newPage(); + for (const shot of SHOTS) { + const url = `${BASE}?id=${shot.id}&viewMode=story&args=`; + console.log(`[${vp.name}] → ${shot.label}`); + await page.goto(url, { waitUntil: "domcontentloaded" }); + // Give the sheet animation + potential network stubs ~1.5s to settle. + await page.waitForTimeout(shot.label === "loading-spinner" ? 900 : 1600); + const out = path.join(OUT_DIR, `${vp.name}-${shot.label}.png`); + await page.screenshot({ path: out, fullPage: false }); + } + // Extra capture: mobile view story (its own viewport story) as a cross-check. + if (vp.name === "mobile") { + await page.goto( + `${BASE}?id=components-file-viewer-sheet--mobile-view&viewMode=story&args=`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForTimeout(1600); + await page.screenshot({ + path: path.join(OUT_DIR, `mobile-story-variant.png`), + fullPage: false, + }); + } + await context.close(); + } + } finally { + await browser.close(); + } + console.log(`\nScreenshots written to ${OUT_DIR}`); +} + +run().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/screenshot-one.mjs b/scripts/screenshot-one.mjs new file mode 100644 index 00000000..cadc79fa --- /dev/null +++ b/scripts/screenshot-one.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Screenshot a single story id at desktop 1440x900. +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium } from "@playwright/test"; + +const [, , storyId, out, widthArg, heightArg, waitArg] = process.argv; +const width = Number(widthArg) || 1440; +const height = Number(heightArg) || 900; +const waitMs = Number(waitArg) || 2500; +const url = `http://localhost:6006/iframe.html?id=${storyId}&viewMode=story`; + +await fs.mkdir(path.dirname(path.resolve(out)), { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const ctx = await browser.newContext({ + viewport: { width, height }, + deviceScaleFactor: 2, + }); + const page = await ctx.newPage(); + await page.goto(url, { waitUntil: "networkidle" }); + await page.waitForTimeout(waitMs); + await page.screenshot({ path: out, fullPage: false }); +} finally { + await browser.close(); +} +console.log(`Wrote ${out}`); diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts new file mode 100644 index 00000000..1f0516aa --- /dev/null +++ b/server/src/__tests__/file-resources.test.ts @@ -0,0 +1,1377 @@ +import express from "express"; +import type { Request } from "express"; +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { activityLog, agents, companies, createDb, executionWorkspaces, goals, issues, projects, projectWorkspaces, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import { errorHandler } from "../middleware/index.js"; +import { + createFileResourceLimiter, + createFileResourceListLimiter, + fileResourceRoutes, + type WorkspaceFileResourceService, +} from "../routes/file-resources.js"; +import { + WORKSPACE_FILE_TEXT_MAX_BYTES, + workspaceFileResourceService, +} from "../services/workspace-file-resources.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +const execFileAsync = promisify(execFile); + +type TestGraph = { + companyId: string; + otherCompanyId: string; + issueId: string; + otherIssueId: string; + projectId: string; + projectWorkspaceId: string; + targetProjectId: string; + targetProjectWorkspaceId: string; + otherProjectId: string; + otherProjectWorkspaceId: string; + workspaceRoot: string; + targetWorkspaceRoot: string; + executionRoot: string; +}; + +async function makeWorkspace() { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-file-resources-")); + const projectRoot = path.join(root, "project"); + const targetProjectRoot = path.join(root, "target-project"); + const executionRoot = path.join(root, "execution"); + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(targetProjectRoot, { recursive: true }); + await fs.mkdir(executionRoot, { recursive: true }); + return { root, projectRoot, targetProjectRoot, executionRoot }; +} + +async function seedGraph(db: Db, input: { + projectRoot: string; + targetProjectRoot?: string; + executionRoot?: string | null; + projectSourceType?: string; + targetProjectSourceType?: string; +}): Promise { + const suffix = crypto.randomUUID().slice(0, 8); + const companyId = crypto.randomUUID(); + const otherCompanyId = crypto.randomUUID(); + const goalId = crypto.randomUUID(); + const otherGoalId = crypto.randomUUID(); + const projectId = crypto.randomUUID(); + const targetProjectId = crypto.randomUUID(); + const otherProjectId = crypto.randomUUID(); + const projectWorkspaceId = crypto.randomUUID(); + const targetProjectWorkspaceId = crypto.randomUUID(); + const otherProjectWorkspaceId = crypto.randomUUID(); + const executionWorkspaceId = crypto.randomUUID(); + const issueId = crypto.randomUUID(); + const otherIssueId = crypto.randomUUID(); + + await db.insert(companies).values([ + { id: companyId, name: `Company ${suffix}`, issuePrefix: `F${suffix.slice(0, 4).toUpperCase()}` }, + { id: otherCompanyId, name: `Other ${suffix}`, issuePrefix: `G${suffix.slice(0, 4).toUpperCase()}` }, + ]); + await db.insert(goals).values([ + { id: goalId, companyId, title: "Goal", level: "company", status: "active" }, + { id: otherGoalId, companyId: otherCompanyId, title: "Other goal", level: "company", status: "active" }, + ]); + await db.insert(projects).values([ + { id: projectId, companyId, goalId, name: "Project", status: "in_progress" }, + { id: targetProjectId, companyId, goalId, name: "Target project", status: "in_progress" }, + { id: otherProjectId, companyId: otherCompanyId, goalId: otherGoalId, name: "Other project", status: "in_progress" }, + ]); + await db.insert(projectWorkspaces).values([ + { + id: projectWorkspaceId, + companyId, + projectId, + name: "Primary workspace", + sourceType: input.projectSourceType ?? "local_path", + cwd: input.projectRoot, + isPrimary: true, + }, + { + id: otherProjectWorkspaceId, + companyId: otherCompanyId, + projectId: otherProjectId, + name: "Other workspace", + sourceType: "local_path", + cwd: input.projectRoot, + isPrimary: true, + }, + { + id: targetProjectWorkspaceId, + companyId, + projectId: targetProjectId, + name: "Target workspace", + sourceType: input.targetProjectSourceType ?? "local_path", + cwd: input.targetProjectRoot ?? input.projectRoot, + isPrimary: true, + }, + ]); + await db.insert(issues).values([ + { + id: issueId, + companyId, + projectId, + goalId, + projectWorkspaceId, + title: "Read a file", + status: "todo", + priority: "medium", + }, + { + id: otherIssueId, + companyId: otherCompanyId, + projectId: otherProjectId, + goalId: otherGoalId, + projectWorkspaceId: otherProjectWorkspaceId, + title: "Other issue", + status: "todo", + priority: "medium", + }, + ]); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId, + sourceIssueId: issueId, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Issue worktree", + status: "active", + cwd: input.executionRoot ?? null, + providerType: input.executionRoot ? "git_worktree" : "remote_managed", + providerRef: input.executionRoot ?? "remote-workspace", + }); + await db.update(issues).set({ executionWorkspaceId }).where(eq(issues.id, issueId)); + + return { + companyId, + otherCompanyId, + issueId, + otherIssueId, + projectId, + projectWorkspaceId, + targetProjectId, + targetProjectWorkspaceId, + otherProjectId, + otherProjectWorkspaceId, + workspaceRoot: input.projectRoot, + targetWorkspaceRoot: input.targetProjectRoot ?? input.projectRoot, + executionRoot: input.executionRoot ?? input.projectRoot, + }; +} + +function createApp( + db: Db, + actor: Request["actor"], + routeOpts: Parameters[1] = {}, +) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = actor; + next(); + }); + app.use("/api", fileResourceRoutes(db, routeOpts)); + app.use(errorHandler); + return app; +} + +describeEmbeddedPostgres("workspace file resources", () => { + let tempDb: Awaited> | null = null; + let db: Db; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-file-resources-"); + db = createDb(tempDb.connectionString); + }, 60_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("resolves and reads a project file without exposing absolute paths", async () => { + const { root, projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", { encoding: "utf8" }).catch(async (error) => { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + await fs.mkdir(path.join(projectRoot, "src"), { recursive: true }); + await fs.writeFile(path.join(projectRoot, "src", "app.ts"), "export const ok = true;\n", "utf8"); + }); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: "src/app.ts" }); + + expect(res.status).toBe(200); + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + expect(res.body.resource.displayPath).toBe("src/app.ts"); + expect(JSON.stringify(res.body)).not.toContain(root); + expect(res.body.content.data).toContain("export const ok"); + }); + + it("falls back from an execution workspace miss to the project workspace", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "README.md"), "# Project\n", "utf8"); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: "README.md", + workspace: "auto", + }); + + expect(resolved.workspaceKind).toBe("project_workspace"); + expect(resolved.displayPath).toBe("README.md"); + expect(resolved.capabilities.preview).toBe(true); + }); + + it("auto-discovers unhinted same-company project files when issue workspaces miss", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const targetPath = "docs/reference/skills.md"; + await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Skills reference\n", "utf8"); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: targetPath, + workspace: "auto", + }); + + expect(resolved).toMatchObject({ + workspaceKind: "project_workspace", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${targetPath}`, + previewKind: "text", + }); + + const content = await workspaceFileResourceService(db).readContent(graph.issueId, { + path: targetPath, + workspace: "auto", + }); + expect(content.resource.workspaceId).toBe(graph.targetProjectWorkspaceId); + expect(content.content.data).toContain("# Skills reference"); + }); + + it("resolves explicit same-company cross-project workspace files and logs target details", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + await fs.mkdir(path.join(targetProjectRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, "docs", "README.md"), "# Target project\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: "docs/README.md", + }); + + expect(res.status).toBe(200); + expect(res.body.resource).toMatchObject({ + workspaceKind: "project_workspace", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: "Target project / docs/README.md", + }); + expect(res.body.content.data).toContain("# Target project"); + expect(JSON.stringify(res.body)).not.toContain(root); + + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const read = rows.find((row) => row.action === "issue.file_resource_content_read"); + expect(read?.details).toMatchObject({ + outcome: "success", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: "Target project / docs/README.md", + }); + expect(JSON.stringify(read?.details)).not.toContain(targetProjectRoot); + }); + + it("reads explicit cross-project git_repo workspaces with a local checkout", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { + projectRoot, + targetProjectRoot, + executionRoot, + targetProjectSourceType: "git_repo", + }); + const readmePath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md"; + await fs.mkdir(path.join(targetProjectRoot, path.dirname(readmePath)), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, readmePath), "# Bundled skills\n\nRendered from content project.\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: readmePath, + }); + + expect(res.status).toBe(200); + expect(res.body.resource).toMatchObject({ + workspaceKind: "project_workspace", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${readmePath}`, + provider: "git_repo", + previewKind: "text", + }); + expect(res.body.content.encoding).toBe("utf8"); + expect(res.body.content.data).toContain("# Bundled skills"); + expect(JSON.stringify(res.body)).not.toContain(root); + }); + + it("resolves and lists explicit same-company cross-project workspace folders", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; + await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8"); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "notes.txt"), "notes\n", "utf8"); + await fs.writeFile(path.join(targetProjectRoot, "outside.txt"), "not in focused folder\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const resolved = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: folderPath, + }); + + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ + kind: "directory", + workspaceKind: "project_workspace", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${folderPath}`, + capabilities: { preview: false, download: false, listChildren: true }, + }); + expect(JSON.stringify(resolved.body)).not.toContain(root); + + const listed = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: folderPath, + mode: "all", + }); + + expect(listed.status).toBe(200); + expect(listed.body.state).toBe("available"); + expect(listed.body.query).toMatchObject({ path: folderPath.slice(0, -1), mode: "all" }); + expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([ + `${folderPath}README.md`, + `${folderPath}notes.txt`, + ])); + expect(JSON.stringify(listed.body)).not.toContain(root); + expect(JSON.stringify(listed.body)).not.toContain("outside.txt"); + }); + + it("auto-discovers unhinted same-company project folders when issue workspaces miss", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; + await fs.mkdir(path.join(targetProjectRoot, folderPath), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "README.md"), "# Bundled skills\n", "utf8"); + await fs.writeFile(path.join(targetProjectRoot, folderPath, "suggestions.md"), "# Suggestions\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const resolved = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ path: folderPath }); + expect(resolved.status).toBe(200); + expect(resolved.body).toMatchObject({ + kind: "directory", + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + displayPath: `Target project / ${folderPath}`, + }); + + const listed = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ path: folderPath }); + expect(listed.status).toBe(200); + expect(listed.body.state).toBe("available"); + expect(listed.body.workspace).toMatchObject({ + workspaceId: graph.targetProjectWorkspaceId, + projectId: graph.targetProjectId, + projectName: "Target project", + }); + expect(new Set(listed.body.items.map((item: { relativePath: string }) => item.relativePath))).toEqual(new Set([ + `${folderPath}README.md`, + `${folderPath}suggestions.md`, + ])); + expect(JSON.stringify(listed.body)).not.toContain(root); + }); + + it("rejects ambiguous unhinted same-company project file matches", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const duplicateProjectId = crypto.randomUUID(); + const duplicateWorkspaceId = crypto.randomUUID(); + const duplicateRoot = path.join(path.dirname(targetProjectRoot), "duplicate-target"); + const targetPath = "docs/reference/skills.md"; + await fs.mkdir(path.join(targetProjectRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(targetProjectRoot, targetPath), "# Target\n", "utf8"); + await fs.mkdir(path.join(duplicateRoot, path.dirname(targetPath)), { recursive: true }); + await fs.writeFile(path.join(duplicateRoot, targetPath), "# Duplicate\n", "utf8"); + await db.insert(projects).values({ + id: duplicateProjectId, + companyId: graph.companyId, + name: "Duplicate target", + status: "in_progress", + }); + await db.insert(projectWorkspaces).values({ + id: duplicateWorkspaceId, + companyId: graph.companyId, + projectId: duplicateProjectId, + name: "Duplicate workspace", + sourceType: "local_path", + cwd: duplicateRoot, + isPrimary: true, + }); + + await expect(workspaceFileResourceService(db).resolve(graph.issueId, { + path: targetPath, + workspace: "auto", + })).rejects.toMatchObject({ + status: 409, + details: { code: "ambiguous_workspace_path" }, + }); + }); + + it("denies explicit cross-company project workspaces", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ + projectId: graph.otherProjectId, + workspaceId: graph.otherProjectWorkspaceId, + path: "README.md", + }); + + expect(res.status).toBe(403); + expect(res.body?.details?.code).toBe("cross_company_workspace"); + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const denied = rows.find((row) => row.action === "issue.file_resource_resolve_denied"); + expect(denied?.details).toMatchObject({ + outcome: "denied", + projectId: graph.otherProjectId, + workspaceId: graph.otherProjectWorkspaceId, + denialReason: "cross_company_workspace", + }); + }); + + it("rejects explicit project/workspace mismatches", async () => { + const { projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ + projectId: graph.targetProjectId, + workspaceId: graph.projectWorkspaceId, + path: "README.md", + }); + + expect(res.status).toBe(422); + expect(res.body?.details?.code).toBe("workspace_project_mismatch"); + }); + + it("blocks symlink escapes from explicit cross-project workspaces", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + await fs.writeFile(path.join(root, "outside-target.txt"), "secret\n", "utf8"); + await fs.symlink(path.join(root, "outside-target.txt"), path.join(targetProjectRoot, "escape.txt")); + + await expect(workspaceFileResourceService(db).readContent(graph.issueId, { + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: "escape.txt", + })).rejects.toMatchObject({ + status: 403, + details: { code: "outside_workspace" }, + }); + }); + + it("blocks symlink directory escapes from explicit cross-project workspaces", async () => { + const { root, projectRoot, targetProjectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot }); + const outsideDir = path.join(root, "outside-dir"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret\n", "utf8"); + await fs.symlink(outsideDir, path.join(targetProjectRoot, "escape-dir")); + + await expect(workspaceFileResourceService(db).resolve(graph.issueId, { + projectId: graph.targetProjectId, + workspaceId: graph.targetProjectWorkspaceId, + path: "escape-dir/", + })).rejects.toMatchObject({ + status: 403, + details: { code: "outside_workspace" }, + }); + }); + + it("resolves and reads video workspace files as base64 previews", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "demo.mp4"), Buffer.from([0, 0, 0, 24, 102, 116, 121, 112])); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: "demo.mp4", + workspace: "project", + }); + expect(resolved.previewKind).toBe("video"); + expect(resolved.contentType).toBe("video/mp4"); + expect(resolved.capabilities.preview).toBe(true); + + const content = await workspaceFileResourceService(db).readContent(graph.issueId, { + path: "demo.mp4", + workspace: "project", + }); + expect(content.resource.previewKind).toBe("video"); + expect(content.content.encoding).toBe("base64"); + expect(content.content.data).toBe(Buffer.from([0, 0, 0, 24, 102, 116, 121, 112]).toString("base64")); + }); + + it("lists and searches safe file candidates from the preferred execution workspace", async () => { + const { root, projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.mkdir(path.join(executionRoot, "src"), { recursive: true }); + await fs.mkdir(path.join(executionRoot, "node_modules", "pkg"), { recursive: true }); + await fs.writeFile(path.join(executionRoot, "src", "app.ts"), "export const ok = true;\n", "utf8"); + await fs.writeFile(path.join(executionRoot, ".env"), "TOKEN=secret\n", "utf8"); + await fs.writeFile(path.join(executionRoot, "node_modules", "pkg", "index.ts"), "export {}\n", "utf8"); + await fs.writeFile(path.join(executionRoot, "blob.bin"), Buffer.from([0, 1, 2, 3])); + await fs.writeFile(path.join(projectRoot, "src-project.ts"), "export const project = true;\n", "utf8"); + await fs.writeFile(path.join(root, "outside.ts"), "secret\n", "utf8"); + await fs.symlink(path.join(root, "outside.ts"), path.join(executionRoot, "escape.ts")); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "auto", q: "src", limit: 10 }); + + expect(res.status).toBe(200); + expect(res.body.state).toBe("available"); + expect(res.body.workspace.workspaceKind).toBe("execution_workspace"); + expect(res.body.items.map((item: { displayPath: string }) => item.displayPath)).toEqual(["src/app.ts"]); + expect(JSON.stringify(res.body)).not.toContain(root); + expect(JSON.stringify(res.body)).not.toContain(".env"); + expect(JSON.stringify(res.body)).not.toContain("node_modules"); + expect(JSON.stringify(res.body)).not.toContain("escape.ts"); + expect(JSON.stringify(res.body)).not.toContain("blob.bin"); + }); + + it("enforces default and hard list/search caps", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await Promise.all( + Array.from({ length: 30 }, (_, index) => + fs.writeFile( + path.join(projectRoot, `file-${String(index).padStart(2, "0")}.ts`), + "export {}\n", + "utf8", + ), + ), + ); + const tooDeepDir = path.join(projectRoot, ...Array.from({ length: 21 }, (_, index) => `deep-${index}`)); + await fs.mkdir(tooDeepDir, { recursive: true }); + await fs.writeFile(path.join(tooDeepDir, "too-deep.ts"), "export const hidden = true;\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const defaultLimit = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "all" }); + expect(defaultLimit.status).toBe(200); + expect(defaultLimit.body.query.limit).toBe(25); + expect(defaultLimit.body.items).toHaveLength(25); + expect(defaultLimit.body.truncated).toBe(true); + + const tooLargeLimit = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "all", limit: 101 }); + expect(tooLargeLimit.status).toBe(422); + expect(tooLargeLimit.body?.details?.code).toBe("invalid_query"); + + const tooDeep = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "all", q: "too-deep", limit: 100 }); + expect(tooDeep.status).toBe(200); + expect(tooDeep.body.items).toEqual([]); + expect(tooDeep.body.truncated).toBe(true); + }); + + it("lists one folder level at a time so deep descendants do not hide selected siblings", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.mkdir(path.join(projectRoot, "docs", "deep"), { recursive: true }); + await fs.writeFile(path.join(projectRoot, "docs", "selected.md"), "# Selected\n", "utf8"); + await Promise.all( + Array.from({ length: 130 }, (_, index) => + fs.writeFile( + path.join(projectRoot, "docs", "deep", `generated-${String(index).padStart(3, "0")}.md`), + "# Generated\n", + "utf8", + ), + ), + ); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const parent = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", path: "docs", mode: "all", limit: 100 }); + + expect(parent.status).toBe(200); + expect(parent.body.query).toMatchObject({ path: "docs", mode: "all", offset: 0 }); + expect(parent.body.truncated).toBe(false); + expect(parent.body.items.map((item: { kind: string; relativePath: string }) => `${item.kind}:${item.relativePath}`)).toEqual([ + "directory:docs/deep", + "file:docs/selected.md", + ]); + + const firstDeepPage = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100 }); + expect(firstDeepPage.status).toBe(200); + expect(firstDeepPage.body.items).toHaveLength(100); + expect(firstDeepPage.body.truncated).toBe(true); + + const secondDeepPage = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", path: "docs/deep", mode: "all", limit: 100, offset: 100 }); + expect(secondDeepPage.status).toBe(200); + expect(secondDeepPage.body.query.offset).toBe(100); + expect(secondDeepPage.body.items).toHaveLength(30); + expect(secondDeepPage.body.truncated).toBe(false); + }); + + it("supports recent mode, limit caps, and list activity logging", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "old.ts"), "export const old = true;\n", "utf8"); + await fs.writeFile(path.join(projectRoot, "new.ts"), "export const newer = true;\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "recent", limit: 1 }); + + expect(res.status).toBe(200); + expect(res.body.items).toHaveLength(1); + expect(res.body.truncated).toBe(true); + + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const listRead = rows.find((row) => row.action === "issue.file_resource_list"); + expect(listRead).toBeTruthy(); + expect(listRead?.details).toMatchObject({ + outcome: "success", + workspaceSelector: "project", + mode: "recent", + resultCount: 1, + truncated: true, + }); + expect(JSON.stringify(listRead?.details)).not.toContain(projectRoot); + expect(JSON.stringify(listRead?.details)).not.toContain("old.ts"); + expect(JSON.stringify(listRead?.details)).not.toContain("new.ts"); + }); + + it("applies list offsets to search, recent, and changed modes", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await execFileAsync("git", ["-C", projectRoot, "init"]); + await Promise.all([ + fs.writeFile(path.join(projectRoot, "alpha.ts"), "export const alpha = true;\n", "utf8"), + fs.writeFile(path.join(projectRoot, "beta.ts"), "export const beta = true;\n", "utf8"), + fs.writeFile(path.join(projectRoot, "gamma.ts"), "export const gamma = true;\n", "utf8"), + ]); + const older = new Date("2026-01-01T00:00:00.000Z"); + const middle = new Date("2026-01-02T00:00:00.000Z"); + const newer = new Date("2026-01-03T00:00:00.000Z"); + await fs.utimes(path.join(projectRoot, "alpha.ts"), older, older); + await fs.utimes(path.join(projectRoot, "beta.ts"), middle, middle); + await fs.utimes(path.join(projectRoot, "gamma.ts"), newer, newer); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const searchPage = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", q: ".ts", limit: 1, offset: 1 }); + expect(searchPage.status).toBe(200); + expect(searchPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); + expect(searchPage.body.truncated).toBe(true); + + const recentPage = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "recent", limit: 1, offset: 1 }); + expect(recentPage.status).toBe(200); + expect(recentPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); + expect(recentPage.body.truncated).toBe(true); + + const changedPage = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", mode: "changed", limit: 1, offset: 1 }); + expect(changedPage.status).toBe(200); + expect(changedPage.body.items.map((item: { relativePath: string }) => item.relativePath)).toEqual(["beta.ts"]); + expect(changedPage.body.truncated).toBe(true); + }); + + it("rejects overlong list searches and redacts the raw query from denial audit details", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const rawQuery = "é".repeat(65); + + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project", q: rawQuery }); + + expect(res.status).toBe(422); + expect(res.body?.details?.code).toBe("invalid_query"); + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const denied = rows.find((row) => row.action === "issue.file_resource_list_denied"); + expect(denied?.details).toMatchObject({ + outcome: "denied", + workspaceSelector: "project", + mode: "all", + denialReason: "invalid_query", + }); + expect(JSON.stringify(denied?.details)).not.toContain(rawQuery); + }); + + it("rejects control characters in the path without crashing the audit log", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const nullByte = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=foo%00bar.ts`); + expect(nullByte.status).toBe(422); + expect(nullByte.body?.details?.code).toBe("invalid_path"); + + const resolveNullByte = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve?workspace=project&path=foo%00bar.ts`); + expect(resolveNullByte.status).toBe(422); + expect(resolveNullByte.body?.details?.code).toBe("invalid_path"); + + const otherControl = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=a%0Bb.ts`); + expect(otherControl.status).toBe(422); + expect(otherControl.body?.details?.code).toBe("invalid_path"); + }); + + it("rejects traversal, encoded traversal, home-relative paths, backslash traversal, and double-encoding without double-decoding", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "safe.txt"), "safe\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=..%2Fsecret.txt`)).status).toBe(403); + expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%2e%2e%2Fsecret.txt`)).status).toBe(403); + expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "~/secret.txt" })).status).toBe(422); + expect((await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "..\\secret.txt" })).status).toBe(422); + const doubleEncoded = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content?workspace=project&path=%252e%252e%252Fsecret.txt`); + expect(doubleEncoded.status).toBe(404); + }); + + it("blocks symlink escapes and symlinks to denied sensitive files", async () => { + const { root, projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(root, "outside-secret.txt"), "secret\n", "utf8"); + await fs.mkdir(path.join(projectRoot, "safe"), { recursive: true }); + await fs.writeFile(path.join(projectRoot, "safe", ".env"), "TOKEN=secret\n", "utf8"); + await fs.symlink(path.join(root, "outside-secret.txt"), path.join(projectRoot, "escape.txt")); + await fs.symlink(path.join(projectRoot, "safe", ".env"), path.join(projectRoot, "linked-env")); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + const escape = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: "escape.txt" }); + const linkedSecret = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: "linked-env" }); + + expect(escape.status).toBe(403); + expect(linkedSecret.status).toBe(403); + }); + + it("rejects denied paths, non-regular files, oversized text, binary, and HTML while previewing SVG as source", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.mkdir(path.join(projectRoot, ".git"), { recursive: true }); + await fs.writeFile(path.join(projectRoot, ".git", "config"), "[core]\n", "utf8"); + await fs.mkdir(path.join(projectRoot, "folder"), { recursive: true }); + await fs.writeFile(path.join(projectRoot, "big.txt"), Buffer.alloc(WORKSPACE_FILE_TEXT_MAX_BYTES + 1, "a")); + await fs.writeFile(path.join(projectRoot, "blob.bin"), Buffer.from([0, 1, 2, 3])); + await fs.writeFile(path.join(projectRoot, "index.html"), "", "utf8"); + await fs.writeFile(path.join(projectRoot, "icon.svg"), "\n", "utf8"); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + for (const filePath of [".git/config", "folder", "big.txt", "blob.bin", "index.html"]) { + const res = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: filePath }); + expect([403, 422]).toContain(res.status); + } + const svg = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ workspace: "project", path: "icon.svg" }); + expect(svg.status).toBe(200); + expect(svg.body.resource.previewKind).toBe("text"); + expect(svg.body.content.data).toContain(" { + const { projectRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { + projectRoot, + executionRoot: null, + projectSourceType: "remote_managed", + }); + + const resolved = await workspaceFileResourceService(db).resolve(graph.issueId, { + path: "README.md", + workspace: "project", + }); + expect(resolved.kind).toBe("remote_resource"); + expect(resolved.capabilities.preview).toBe(false); + + await expect(workspaceFileResourceService(db).readContent(graph.issueId, { + path: "http://169.254.169.254/latest/meta-data/", + workspace: "project", + })).rejects.toMatchObject({ status: 422 }); + + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + const listed = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/list`) + .query({ workspace: "project" }); + expect(listed.status).toBe(200); + expect(listed.body.state).toBe("unavailable"); + expect(listed.body.unavailableReason).toBe("remote_workspace"); + expect(listed.body.items).toEqual([]); + }); + + it("blocks agents and cross-company board users before content reads", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "README.md"), "# Secret\n", "utf8"); + const agentId = crypto.randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId: graph.companyId, + name: "File audit agent", + role: "engineer", + adapterType: "process", + adapterConfig: {}, + }); + + const agentApp = createApp(db, { + type: "agent", + agentId, + companyId: graph.companyId, + source: "agent_key", + }); + const boardApp = createApp(db, { + type: "board", + userId: "mallory", + companyIds: [graph.otherCompanyId], + source: "session", + isInstanceAdmin: false, + }); + + expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/resolve`).query({ path: "README.md" })).status).toBe(403); + expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/resolve`).query({ path: "README.md" })).status).toBe(403); + expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(403); + expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ path: "README.md" })).status).toBe(403); + expect((await request(agentApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(403); + expect((await request(boardApp).get(`/api/issues/${graph.issueId}/file-resources/list`)).status).toBe(403); + + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const listDenials = rows.filter((row) => row.action === "issue.file_resource_list_denied"); + const resolveDenials = rows.filter((row) => row.action === "issue.file_resource_resolve_denied"); + const contentDenials = rows.filter((row) => row.action === "issue.file_resource_content_denied"); + expect(listDenials).toHaveLength(2); + expect(resolveDenials).toHaveLength(2); + expect(contentDenials).toHaveLength(2); + expect(JSON.stringify(listDenials.map((row) => row.details))).not.toContain("README.md"); + expect(JSON.stringify(rows.map((row) => row.details))).not.toContain(projectRoot); + }); + + it("logs successful content reads and denied security-relevant attempts", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "README.md"), "# Project\n", "utf8"); + await fs.writeFile(path.join(projectRoot, ".env"), "TOKEN=secret\n", "utf8"); + const app = createApp(db, { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }); + + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: "README.md" }); + await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ workspace: "project", path: ".env" }); + + const rows = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + expect(rows.some((row) => row.action === "issue.file_resource_content_read")).toBe(true); + expect(rows.some((row) => row.action === "issue.file_resource_content_denied")).toBe(true); + expect(JSON.stringify(rows.map((row) => row.details))).not.toContain(projectRoot); + }); + + it("logs successful resolves and resolve/content limiter denials", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + await fs.writeFile(path.join(projectRoot, "README.md"), "# Visible\n", "utf8"); + + const app = createApp( + db, + { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }, + { + limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), + }, + ); + + const resolved = await request(app) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ workspace: "project", path: "README.md" }); + expect(resolved.status).toBe(200); + + const rowsAfterResolve = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + const resolveRead = rowsAfterResolve.find((row) => row.action === "issue.file_resource_resolve"); + expect(resolveRead?.details).toMatchObject({ + outcome: "success", + workspaceKind: "project_workspace", + displayPath: "README.md", + }); + expect(JSON.stringify(resolveRead?.details)).not.toContain(projectRoot); + + let releaseSlowResolve: (() => void) | null = null; + let slowResolveStarted: (() => void) | null = null; + const slowResolve = new Promise((resolve) => { + releaseSlowResolve = resolve; + }); + const resolveStarted = new Promise((resolve) => { + slowResolveStarted = resolve; + }); + const resolveLimitedService: WorkspaceFileResourceService = { + getIssue: vi.fn(async () => ({ companyId: graph.companyId })), + list: vi.fn(async () => { + throw new Error("not used"); + }), + resolve: vi.fn(async () => { + slowResolveStarted?.(); + await slowResolve; + return { + kind: "file", + provider: "local_fs", + title: "README.md", + displayPath: "README.md", + workspaceLabel: "Workspace", + workspaceKind: "project_workspace", + workspaceId: "11111111-1111-4111-8111-111111111111", + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, + }; + }), + readContent: vi.fn(async () => { + throw new Error("not used"); + }), + }; + const resolveLimitedApp = createApp( + db, + { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }, + { + service: resolveLimitedService, + limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), + }, + ); + const firstResolve = request(resolveLimitedApp) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ path: "README.md" }); + const firstResolveResponse = firstResolve.then((res) => res); + await resolveStarted; + const secondResolve = await request(resolveLimitedApp) + .get(`/api/issues/${graph.issueId}/file-resources/resolve`) + .query({ path: "README.md" }); + expect(secondResolve.status).toBe(429); + releaseSlowResolve?.(); + expect((await firstResolveResponse).status).toBe(200); + + let releaseSlowContent: (() => void) | null = null; + let slowContentStarted: (() => void) | null = null; + const slowContent = new Promise((resolve) => { + releaseSlowContent = resolve; + }); + const contentStarted = new Promise((resolve) => { + slowContentStarted = resolve; + }); + const contentLimitedService: WorkspaceFileResourceService = { + getIssue: vi.fn(async () => ({ companyId: graph.companyId })), + list: vi.fn(async () => { + throw new Error("not used"); + }), + resolve: vi.fn(async () => { + throw new Error("not used"); + }), + readContent: vi.fn(async () => { + slowContentStarted?.(); + await slowContent; + return { + resource: { + kind: "file", + provider: "local_fs", + title: "README.md", + displayPath: "README.md", + workspaceLabel: "Workspace", + workspaceKind: "project_workspace", + workspaceId: "11111111-1111-4111-8111-111111111111", + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, + }, + content: { encoding: "utf8", data: "# Visible\n" }, + }; + }), + }; + const contentLimitedApp = createApp( + db, + { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }, + { + service: contentLimitedService, + limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 100, windowMs: 60_000 }), + }, + ); + const firstContent = request(contentLimitedApp) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ path: "README.md" }); + const firstContentResponse = firstContent.then((res) => res); + await contentStarted; + const secondContent = await request(contentLimitedApp) + .get(`/api/issues/${graph.issueId}/file-resources/content`) + .query({ path: "README.md" }); + expect(secondContent.status).toBe(429); + releaseSlowContent?.(); + expect((await firstContentResponse).status).toBe(200); + + const rowsAfterLimits = await db.select().from(activityLog).where(eq(activityLog.entityId, graph.issueId)); + expect(rowsAfterLimits.some((row) => row.action === "issue.file_resource_resolve_denied")).toBe(true); + expect(rowsAfterLimits.some((row) => row.action === "issue.file_resource_content_denied")).toBe(true); + expect(JSON.stringify(rowsAfterLimits.map((row) => row.details))).not.toContain(projectRoot); + }); + + it("uses tighter list-specific rate and concurrency limits", async () => { + const { projectRoot, executionRoot } = await makeWorkspace(); + const graph = await seedGraph(db, { projectRoot, executionRoot }); + let releaseSlowList: (() => void) | null = null; + let slowListStarted: (() => void) | null = null; + const slowList = new Promise((resolve) => { + releaseSlowList = resolve; + }); + const listStarted = new Promise((resolve) => { + slowListStarted = resolve; + }); + const service: WorkspaceFileResourceService = { + getIssue: vi.fn(async () => ({ companyId: graph.companyId })), + list: vi.fn(async () => { + slowListStarted?.(); + await slowList; + return { + kind: "workspace_file_list", + state: "available", + workspace: { + provider: "local_fs", + workspaceLabel: "Workspace", + workspaceKind: "project_workspace", + workspaceId: "11111111-1111-4111-8111-111111111111", + }, + query: { + workspace: "auto", + mode: "all", + q: null, + limit: 25, + }, + items: [], + scannedCount: 0, + truncated: false, + }; + }), + resolve: vi.fn(async () => { + throw new Error("not used"); + }), + readContent: vi.fn(async () => { + throw new Error("not used"); + }), + }; + const app = createApp( + db, + { + type: "board", + userId: "board-user", + companyIds: [graph.companyId], + source: "session", + isInstanceAdmin: false, + }, + { + service, + listLimiter: createFileResourceListLimiter({ maxConcurrent: 1, maxRequests: 2, windowMs: 60_000 }), + }, + ); + + const first = request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); + const firstResponse = first.then((res) => res); + await listStarted; + const second = await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); + expect(second.status).toBe(429); + releaseSlowList?.(); + expect((await firstResponse).status).toBe(200); + const third = await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`); + expect(third.status).toBe(429); + }); +}); + +describeEmbeddedPostgres("file resource route guards", () => { + let tempDb: Awaited> | null = null; + let db: Db; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-file-resource-guards-"); + db = createDb(tempDb.connectionString); + }, 60_000); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("enforces bounded rate and concurrency limits", async () => { + const companyId = crypto.randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Rate limit company", + issuePrefix: "RAT", + }); + let releaseSlowRead: (() => void) | null = null; + let slowReadStarted: (() => void) | null = null; + const slowRead = new Promise((resolve) => { + releaseSlowRead = resolve; + }); + const readStarted = new Promise((resolve) => { + slowReadStarted = resolve; + }); + const service: WorkspaceFileResourceService = { + getIssue: vi.fn(async () => ({ companyId })), + list: vi.fn(async () => { + throw new Error("not used"); + }), + resolve: vi.fn(async () => { + slowReadStarted?.(); + await slowRead; + return { + kind: "file", + provider: "local_fs", + title: "README.md", + displayPath: "README.md", + workspaceLabel: "Workspace", + workspaceKind: "project_workspace", + workspaceId: "11111111-1111-4111-8111-111111111111", + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, + }; + }), + readContent: vi.fn(async () => { + throw new Error("not used"); + }), + }; + const app = express(); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId: "board-user", + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", fileResourceRoutes(db, { + service, + limiter: createFileResourceLimiter({ maxConcurrent: 1, maxRequests: 2, windowMs: 60_000 }), + })); + app.use(errorHandler); + + const first = request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); + const firstResponse = first.then((res) => res); + await readStarted; + const second = await request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); + expect(second.status).toBe(429); + releaseSlowRead?.(); + expect((await firstResponse).status).toBe(200); + const third = await request(app).get("/api/issues/issue-1/file-resources/resolve").query({ path: "README.md" }); + expect(third.status).toBe(429); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 0b575d26..95a760bc 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -65,6 +65,7 @@ describe("instance settings routes", () => { enableEnvironments: false, enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -84,6 +85,7 @@ describe("instance settings routes", () => { enableEnvironments: true, enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, @@ -128,6 +130,7 @@ describe("instance settings routes", () => { enableEnvironments: false, enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: true, diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index cc4f4a23..dd71dd5b 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -7,6 +7,7 @@ describe("instance settings service", () => { enableEnvironments: true, enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, @@ -17,6 +18,7 @@ describe("instance settings service", () => { enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: false, enableIssuePlanDecompositions: true, + enableExperimentalFileViewer: true, enableCloudSync: true, autoRestartDevServerWhenIdle: true, enableIssueGraphLivenessAutoRecovery: true, diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 36d0bafb..cf7cb910 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -25,6 +25,7 @@ const apiPrefixes: Record = { "dashboard.ts": "/api", "environments.ts": "/api", "execution-workspaces.ts": "/api", + "file-resources.ts": "/api", "goals.ts": "/api", "health.ts": "/api/health", "inbox-dismissals.ts": "/api", diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 9c776357..51bad01f 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -1,6 +1,7 @@ -import { execFile } from "node:child_process"; -import { randomUUID } from "node:crypto"; +import { execFile, spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; import fs from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -37,7 +38,7 @@ import { stopRuntimeServicesForExecutionWorkspace, type RealizedExecutionWorkspace, } from "../services/workspace-runtime.ts"; -import { writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts"; +import { readLocalServicePortOwner, writeLocalServiceRegistryRecord } from "../services/local-service-supervisor.ts"; import { resolvePaperclipConfigPath } from "../paths.ts"; import type { WorkspaceOperation } from "@paperclipai/shared"; import type { WorkspaceOperationRecorder } from "../services/workspace-operations.ts"; @@ -47,6 +48,17 @@ import { } from "./helpers/embedded-postgres.js"; const execFileAsync = promisify(execFile); + +function stableStringifyForTest(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringifyForTest(entry)).join(",")}]`; + } + if (value && typeof value === "object") { + const rec = value as Record; + return `{${Object.keys(rec).sort().map((key) => `${JSON.stringify(key)}:${stableStringifyForTest(rec[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} const leasedRunIds = new Set(); const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -2915,6 +2927,34 @@ describe("resolveShell (shell fallback)", () => { }); }); +describe("readLocalServicePortOwner", () => { + it("detects the owner of a listening TCP port", async () => { + try { + await execFileAsync("lsof", ["-v"]); + } catch { + return; + } + + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : null; + expect(port).toBeTypeOf("number"); + + const owner = await readLocalServicePortOwner(port!); + expect(owner).toBe(process.pid); + } finally { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + } + }); +}); + describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { let db!: ReturnType; let tempDb: Awaited> | null = null; @@ -3024,6 +3064,7 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { expect(service?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); await expect(fetch(service!.url!)).resolves.toMatchObject({ ok: true }); + await fs.rm(paperclipHome, { recursive: true, force: true }); await resetRuntimeServicesForTests(); const result = await reconcilePersistedRuntimeServicesOnStartup(db); @@ -3046,6 +3087,224 @@ describeEmbeddedPostgres("workspace runtime startup reconciliation", () => { await expect(fetch(service!.url!)).rejects.toThrow(); }); + it("does not reuse a stopped auto-port service port while another process owns it", async () => { + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-unhealthy-adopt-")); + const paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-home-")); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = `runtime-unhealthy-adopt-${randomUUID()}`; + + const portProbe = net.createServer(); + await new Promise((resolve) => portProbe.listen(0, "127.0.0.1", resolve)); + const address = portProbe.address(); + const stalePort = typeof address === "object" && address ? address.port : null; + await new Promise((resolve, reject) => { + portProbe.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); + expect(stalePort).toBeTypeOf("number"); + + const companyId = randomUUID(); + const agentId = randomUUID(); + const projectId = randomUUID(); + const runId = randomUUID(); + const executionWorkspaceId = randomUUID(); + const stoppedServiceId = randomUUID(); + const serviceCommand = + "node -e \"const http=require('node:http'); const stale=process.env.STALE_HEALTH==='1'; http.createServer((req,res)=>{ if (req.url==='/api/health' && stale) { res.statusCode=503; res.end('database_unreachable'); return; } res.end('ok'); }).listen(Number(process.env.PORT), '127.0.0.1')\""; + const scopeType = "agent"; + const scopeId = agentId; + const reuseKey = createHash("sha256") + .update( + stableStringifyForTest({ + scopeType, + scopeId, + serviceName: "paperclip-dev", + command: serviceCommand, + cwd: workspaceRoot, + port: null, + env: {}, + }), + ) + .digest("hex"); + + const staleProcess = spawn(resolveShell(), ["-lc", serviceCommand], { + cwd: workspaceRoot, + env: { + ...process.env, + PORT: String(stalePort), + STALE_HEALTH: "1", + }, + detached: process.platform !== "win32", + stdio: "ignore", + }); + staleProcess.unref(); + + try { + const rootUrl = `http://127.0.0.1:${stalePort}`; + const healthUrl = `${rootUrl}/api/health`; + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(rootUrl); + if (response.ok) break; + } catch { + // Keep polling until the stale process has bound its port. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await expect(fetch(rootUrl)).resolves.toMatchObject({ ok: true }); + await expect(fetch(healthUrl)).resolves.toMatchObject({ ok: false, status: 503 }); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Codex Coder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId, + agentId, + invocationSource: "manual", + status: "running", + startedAt: new Date(), + updatedAt: new Date(), + }); + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Runtime unhealthy adoption test", + status: "in_progress", + }); + await db.insert(executionWorkspaces).values({ + id: executionWorkspaceId, + companyId, + projectId, + projectWorkspaceId: null, + mode: "isolated_workspace", + strategyType: "git_worktree", + name: "Runtime unhealthy adoption", + status: "active", + cwd: workspaceRoot, + providerType: "git_worktree", + providerRef: workspaceRoot, + }); + await db.insert(workspaceRuntimeServices).values({ + id: stoppedServiceId, + companyId, + projectId, + projectWorkspaceId: null, + executionWorkspaceId, + issueId: null, + scopeType, + scopeId, + serviceName: "paperclip-dev", + status: "stopped", + lifecycle: "shared", + reuseKey, + command: serviceCommand, + cwd: workspaceRoot, + port: stalePort, + url: rootUrl, + provider: "local_process", + providerRef: String(staleProcess.pid ?? ""), + ownerAgentId: null, + startedByRunId: null, + lastUsedAt: new Date(), + startedAt: new Date(), + stoppedAt: new Date(), + stopPolicy: { type: "manual" }, + healthStatus: "unknown", + }); + + leasedRunIds.add(runId); + const services = await ensureRuntimeServicesForRun({ + db, + runId, + agent: { + id: agentId, + name: "Codex Coder", + companyId, + }, + issue: null, + workspace: { + ...buildWorkspace(workspaceRoot), + projectId, + workspaceId: null, + }, + executionWorkspaceId, + config: { + workspaceRuntime: { + services: [ + { + name: "paperclip-dev", + command: serviceCommand, + cwd: ".", + port: { type: "auto" }, + readiness: { + type: "http", + urlTemplate: "http://127.0.0.1:{{port}}", + timeoutSec: 10, + intervalMs: 100, + }, + expose: { + type: "url", + urlTemplate: "http://127.0.0.1:{{port}}", + }, + lifecycle: "shared", + reuseScope: "agent", + stopPolicy: { + type: "manual", + }, + }, + ], + }, + }, + adapterEnv: {}, + }); + + expect(services).toHaveLength(1); + expect(services[0]?.reused).toBe(false); + expect(services[0]?.id).toBe(stoppedServiceId); + expect(services[0]?.port).not.toBe(stalePort); + expect(services[0]?.url).not.toBe(rootUrl); + await expect(fetch(services[0]!.url!)).resolves.toMatchObject({ ok: true }); + await expect(fetch(healthUrl)).resolves.toMatchObject({ ok: false, status: 503 }); + expect(await readLocalServicePortOwner(stalePort!)).toBe(staleProcess.pid); + } finally { + leasedRunIds.delete(runId); + await releaseRuntimeServicesForRun(runId); + await stopRuntimeServicesForExecutionWorkspace({ + db, + executionWorkspaceId, + workspaceCwd: workspaceRoot, + }); + if (staleProcess.pid) { + try { + process.kill(-staleProcess.pid, "SIGKILL"); + } catch { + try { + process.kill(staleProcess.pid, "SIGKILL"); + } catch { + // Ignore cleanup races. + } + } + } + } + }, 20_000); + it("marks persisted local services stopped when the registry pid is stale", async () => { const companyId = randomUUID(); const runtimeServiceId = randomUUID(); diff --git a/server/src/app.ts b/server/src/app.ts index 40061e11..e91bc00a 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -17,6 +17,7 @@ import { agentRoutes } from "./routes/agents.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; +import { fileResourceRoutes } from "./routes/file-resources.js"; import { routineRoutes } from "./routes/routines.js"; import { environmentRoutes } from "./routes/environments.js"; import { executionWorkspaceRoutes } from "./routes/execution-workspaces.js"; @@ -222,6 +223,7 @@ export async function createApp( pluginWorkerManager: workerManager, })); api.use(issueTreeControlRoutes(db)); + api.use(fileResourceRoutes(db)); api.use(routineRoutes(db, { pluginWorkerManager: workerManager })); api.use(environmentRoutes(db, { pluginWorkerManager: workerManager })); api.use(executionWorkspaceRoutes(db)); diff --git a/server/src/onboarding-assets/default/AGENTS.md b/server/src/onboarding-assets/default/AGENTS.md index 71b89e3e..3876da66 100644 --- a/server/src/onboarding-assets/default/AGENTS.md +++ b/server/src/onboarding-assets/default/AGENTS.md @@ -5,7 +5,7 @@ You are an agent at Paperclip company. - Start actionable work in the same heartbeat. Do not stop at a plan unless the issue explicitly asks for planning. - Keep the work moving until it is done. If you need QA to review it, ask them. If you need your boss to review it, ask them. - Leave durable progress in task comments, documents, or work products, then update the issue to a clear final disposition before you exit. -- When your work produces a user-inspectable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. +- When your work produces a user-inspectable deliverable file, follow the Paperclip skill's "Generated Artifacts and Work Products" workflow before final disposition. Use `skills/paperclip/scripts/paperclip-upload-artifact.sh` when working in this repo, create/update an artifact work product when the file is the deliverable, and link the uploaded attachment in the final comment. Do not rely on local filesystem paths as the only access path. If an important file intentionally remains workspace-only, create/update a work product with `metadata.resourceRef.kind: "workspace_file"` and a workspace-relative path, then name that work product and path in the final comment. Treat browse/search as a fallback for recovering workspace files, not the preferred deliverable path. - Comments, documents, screenshots, work products, and `Remaining` bullets are evidence, not valid liveness paths by themselves. - Final disposition checklist: mark `done` when complete and verified; use `in_review` only with a real reviewer, approval, interaction, or monitor path; use `blocked` only with first-class blockers or a named unblock owner/action; create delegated follow-up issues with blockers when another agent owns the next step; keep `in_progress` only when a live continuation path exists. - Use child issues for parallel or long delegated work instead of polling agents, sessions, or processes. diff --git a/server/src/routes/file-resources.ts b/server/src/routes/file-resources.ts new file mode 100644 index 00000000..2f2ca638 --- /dev/null +++ b/server/src/routes/file-resources.ts @@ -0,0 +1,644 @@ +import { Router } from "express"; +import { ZodError } from "zod"; +import type { Db } from "@paperclipai/db"; +import { + workspaceFileListQuerySchema, + workspaceFileResourceQuerySchema, + type ResolvedWorkspaceResource, + type WorkspaceFileContent, + type WorkspaceFileListResponse, +} from "@paperclipai/shared"; +import { HttpError, unprocessable } from "../errors.js"; +import { workspaceFileResourceService } from "../services/index.js"; +import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { logActivity } from "../services/activity-log.js"; + +export type WorkspaceFileResourceService = { + getIssue(issueId: string): Promise<{ companyId: string }>; + list(issueId: string, input: { + workspace?: "auto" | "execution" | "project" | null; + projectId?: string | null; + workspaceId?: string | null; + path?: string | null; + mode?: "all" | "recent" | "changed" | null; + q?: string | null; + limit?: number | null; + offset?: number | null; + }, opts?: { issue?: Awaited> }): Promise; + resolve( + issueId: string, + input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null }, + opts?: { issue?: Awaited> }, + ): Promise; + readContent( + issueId: string, + input: { path: string; workspace?: "auto" | "execution" | "project" | null; projectId?: string | null; workspaceId?: string | null }, + opts?: { issue?: Awaited> }, + ): Promise; +}; + +type FileResourceLimiter = { + acquire(key: string): () => void; +}; + +export function createFileResourceLimiter(opts: { + maxConcurrent?: number; + maxRequests?: number; + windowMs?: number; + requestLimitMessage?: string; + concurrencyLimitMessage?: string; +} = {}): FileResourceLimiter { + const maxConcurrent = opts.maxConcurrent ?? 6; + const maxRequests = opts.maxRequests ?? 120; + const windowMs = opts.windowMs ?? 60_000; + const requestLimitMessage = opts.requestLimitMessage ?? "Too many file preview requests"; + const concurrencyLimitMessage = opts.concurrencyLimitMessage ?? "Too many concurrent file preview requests"; + const activeByKey = new Map(); + const windowsByKey = new Map(); + + return { + acquire(key: string) { + const now = Date.now(); + for (const [windowKey, existing] of windowsByKey) { + if (now - existing.startedAt >= windowMs) windowsByKey.delete(windowKey); + } + const window = windowsByKey.get(key); + if (!window || now - window.startedAt >= windowMs) { + windowsByKey.set(key, { startedAt: now, count: 1 }); + } else { + window.count += 1; + if (window.count > maxRequests) { + throw new HttpError(429, requestLimitMessage, { code: "rate_limited" }); + } + } + + const active = activeByKey.get(key) ?? 0; + if (active >= maxConcurrent) { + throw new HttpError(429, concurrencyLimitMessage, { code: "concurrency_limited" }); + } + activeByKey.set(key, active + 1); + return () => { + const current = activeByKey.get(key) ?? 0; + if (current <= 1) activeByKey.delete(key); + else activeByKey.set(key, current - 1); + }; + }, + }; +} + +export function createFileResourceListLimiter(opts: { + maxConcurrent?: number; + maxRequests?: number; + windowMs?: number; +} = {}): FileResourceLimiter { + return createFileResourceLimiter({ + maxConcurrent: opts.maxConcurrent ?? 2, + maxRequests: opts.maxRequests ?? 30, + windowMs: opts.windowMs, + requestLimitMessage: "Too many workspace file list requests", + concurrencyLimitMessage: "Too many concurrent workspace file list requests", + }); +} + +function limiterKey(companyId: string, actorId: string, issueId: string) { + return `${companyId}:${actorId}:${issueId}`; +} + +function readQuery(query: unknown) { + let parsed; + try { + parsed = workspaceFileResourceQuerySchema.parse(query); + } catch (error) { + if (error instanceof ZodError) { + const refinement = error.errors.find((issue) => { + const code = (issue as { params?: { code?: string } }).params?.code; + return code === "invalid_path" || code === "invalid_target"; + }); + const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code; + if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_path" }); + } + throw error; + } + return { + path: parsed.path, + workspace: parsed.workspace ?? "auto", + projectId: parsed.projectId ?? null, + workspaceId: parsed.workspaceId ?? null, + }; +} + +function readListQuery(query: unknown) { + let parsed; + try { + parsed = workspaceFileListQuerySchema.parse(query); + } catch (error) { + if (error instanceof ZodError) { + const refinement = error.errors.find((issue) => { + const code = (issue as { params?: { code?: string } }).params?.code; + return code === "invalid_query" || code === "invalid_target" || code === "invalid_path"; + }); + const code = (refinement as { params?: { code?: string } } | undefined)?.params?.code; + if (refinement) throw unprocessable(refinement.message, { code: code ?? "invalid_query" }); + throw unprocessable("Workspace file list query is invalid", { code: "invalid_query" }); + } + throw error; + } + return { + workspace: parsed.workspace ?? "auto", + projectId: parsed.projectId ?? null, + workspaceId: parsed.workspaceId ?? null, + path: parsed.path ?? null, + mode: parsed.mode ?? "all", + q: parsed.q?.trim() || null, + limit: parsed.limit, + offset: parsed.offset, + }; +} + +function activityDetails(input: { + outcome: "success" | "denied" | "unavailable"; + workspaceKind?: string | null; + workspaceId?: string | null; + projectId?: string | null; + projectName?: string | null; + displayPath?: string | null; + denialReason?: string | null; + byteSize?: number | null; + contentType?: string | null; +}) { + return { + outcome: input.outcome, + ...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.projectName ? { projectName: input.projectName } : {}), + ...(input.displayPath ? { displayPath: input.displayPath } : {}), + ...(input.denialReason ? { denialReason: input.denialReason } : {}), + ...(typeof input.byteSize === "number" ? { byteSize: input.byteSize } : {}), + ...(input.contentType ? { contentType: input.contentType } : {}), + }; +} + +function listActivityDetails(input: { + outcome: "success" | "denied" | "unavailable"; + workspaceSelector: "auto" | "execution" | "project"; + mode: "all" | "recent" | "changed"; + workspaceKind?: string | null; + workspaceId?: string | null; + projectId?: string | null; + projectName?: string | null; + resultCount?: number | null; + scannedCount?: number | null; + truncated?: boolean | null; + denialReason?: string | null; +}) { + return { + outcome: input.outcome, + workspaceSelector: input.workspaceSelector, + mode: input.mode, + ...(input.workspaceKind ? { workspaceKind: input.workspaceKind } : {}), + ...(input.workspaceId ? { workspaceId: input.workspaceId } : {}), + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.projectName ? { projectName: input.projectName } : {}), + ...(typeof input.resultCount === "number" ? { resultCount: input.resultCount } : {}), + ...(typeof input.scannedCount === "number" ? { scannedCount: input.scannedCount } : {}), + ...(typeof input.truncated === "boolean" ? { truncated: input.truncated } : {}), + ...(input.denialReason ? { denialReason: input.denialReason } : {}), + }; +} + +function safeListAuditQuery(query: unknown): { + workspace: "auto" | "execution" | "project"; + mode: "all" | "recent" | "changed"; +} { + if (!query || typeof query !== "object") return { workspace: "auto", mode: "all" }; + const record = query as Record; + const workspace = typeof record.workspace === "string" && ["auto", "execution", "project"].includes(record.workspace) + ? (record.workspace as "auto" | "execution" | "project") + : "auto"; + const mode = typeof record.mode === "string" && ["all", "recent", "changed"].includes(record.mode) + ? (record.mode as "all" | "recent" | "changed") + : "all"; + return { workspace, mode }; +} + +function safeAuditTarget(query: unknown): { projectId: string | null; workspaceId: string | null } { + if (!query || typeof query !== "object") return { projectId: null, workspaceId: null }; + const record = query as Record; + return { + projectId: typeof record.projectId === "string" ? record.projectId : null, + workspaceId: typeof record.workspaceId === "string" ? record.workspaceId : null, + }; +} + +function safeAuditDisplayPath(query: unknown): string { + if (!query || typeof query !== "object") return ""; + const path = (query as Record).path; + if (typeof path !== "string") return ""; + if (/[\x00-\x1f\x7f]/.test(path)) return ""; + return path; +} + +function denialReasonFromError(error: unknown) { + if (!(error instanceof HttpError)) return "unknown"; + const details = error.details; + if (details && typeof details === "object" && "code" in details) { + const code = (details as { code?: unknown }).code; + if (typeof code === "string" && code.length > 0) return code; + } + return error.message; +} + +export function fileResourceRoutes(db: Db, opts: { + service?: WorkspaceFileResourceService; + limiter?: FileResourceLimiter; + listLimiter?: FileResourceLimiter; +} = {}) { + const router = Router(); + const svc = opts.service ?? workspaceFileResourceService(db); + const limiter = opts.limiter ?? createFileResourceLimiter(); + const listLimiter = opts.listLimiter ?? createFileResourceListLimiter(); + + async function logDeniedAttempt(input: { + companyId: string; + actor: ReturnType; + issueId: string; + displayPath: string; + projectId?: string | null; + workspaceId?: string | null; + error: unknown; + action?: "issue.file_resource_content_denied" | "issue.file_resource_resolve_denied"; + }) { + await logActivity(db, { + companyId: input.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + action: input.action ?? "issue.file_resource_content_denied", + entityType: "issue", + entityId: input.issueId, + agentId: input.actor.agentId, + runId: input.actor.runId, + details: activityDetails({ + outcome: "denied", + displayPath: input.displayPath, + projectId: input.projectId, + workspaceId: input.workspaceId, + denialReason: denialReasonFromError(input.error), + }), + }); + } + + async function logListDeniedAttempt(input: { + companyId: string; + actor: ReturnType; + issueId: string; + query: { workspace: "auto" | "execution" | "project"; mode: "all" | "recent" | "changed" }; + target?: { projectId: string | null; workspaceId: string | null }; + error: unknown; + }) { + await logActivity(db, { + companyId: input.companyId, + actorType: input.actor.actorType, + actorId: input.actor.actorId, + action: "issue.file_resource_list_denied", + entityType: "issue", + entityId: input.issueId, + agentId: input.actor.agentId, + runId: input.actor.runId, + details: listActivityDetails({ + outcome: "denied", + workspaceSelector: input.query.workspace, + mode: input.query.mode, + projectId: input.target?.projectId ?? null, + workspaceId: input.target?.workspaceId ?? null, + denialReason: denialReasonFromError(input.error), + }), + }); + } + + router.get("/issues/:issueId/file-resources/list", async (req, res) => { + const auditQuery = safeListAuditQuery(req.query); + const auditTarget = safeAuditTarget(req.query); + try { + assertBoard(req); + } catch (error) { + if (req.actor.type === "agent" && req.actor.companyId) { + await logListDeniedAttempt({ + companyId: req.actor.companyId, + actor: getActorInfo(req), + issueId: req.params.issueId, + query: auditQuery, + target: auditTarget, + error, + }); + } + throw error; + } + const issue = await svc.getIssue(req.params.issueId); + const actor = getActorInfo(req); + try { + assertCompanyAccess(req, issue.companyId); + } catch (error) { + await logListDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + query: auditQuery, + target: auditTarget, + error, + }); + throw error; + } + + let query: ReturnType; + try { + query = readListQuery(req.query); + } catch (error) { + await logListDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + query: auditQuery, + target: auditTarget, + error, + }); + throw error; + } + + let release: (() => void) | null = null; + try { + release = listLimiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); + } catch (error) { + await logListDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + query, + target: { projectId: query.projectId, workspaceId: query.workspaceId }, + error, + }); + throw error; + } + + try { + const result = await svc.list(req.params.issueId, query, { issue }); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "issue.file_resource_list", + entityType: "issue", + entityId: req.params.issueId, + agentId: actor.agentId, + runId: actor.runId, + details: listActivityDetails({ + outcome: result.state === "available" ? "success" : "unavailable", + workspaceSelector: result.query.workspace, + mode: result.query.mode, + workspaceKind: result.workspace?.workspaceKind ?? null, + workspaceId: result.workspace?.workspaceId ?? null, + projectId: result.workspace?.projectId ?? null, + projectName: result.workspace?.projectName ?? null, + resultCount: result.items.length, + scannedCount: result.scannedCount, + truncated: result.truncated, + denialReason: result.unavailableReason ?? null, + }), + }); + res.json(result); + } catch (error) { + await logListDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + query, + target: { projectId: query.projectId, workspaceId: query.workspaceId }, + error, + }); + throw error; + } finally { + release?.(); + } + }); + + router.get("/issues/:issueId/file-resources/resolve", async (req, res) => { + const auditTarget = safeAuditTarget(req.query); + try { + assertBoard(req); + } catch (error) { + if (req.actor.type === "agent" && req.actor.companyId) { + await logDeniedAttempt({ + companyId: req.actor.companyId, + actor: getActorInfo(req), + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + } + throw error; + } + const issue = await svc.getIssue(req.params.issueId); + const actor = getActorInfo(req); + try { + assertCompanyAccess(req, issue.companyId); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + throw error; + } + let query: ReturnType; + try { + query = readQuery(req.query); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + throw error; + } + let release: (() => void) | null = null; + try { + release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + throw error; + } + try { + const result = await svc.resolve(req.params.issueId, query, { issue }); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "issue.file_resource_resolve", + entityType: "issue", + entityId: req.params.issueId, + agentId: actor.agentId, + runId: actor.runId, + details: activityDetails({ + outcome: "success", + workspaceKind: result.workspaceKind, + workspaceId: result.workspaceId, + projectId: result.projectId ?? null, + projectName: result.projectName ?? null, + displayPath: result.displayPath, + byteSize: result.byteSize ?? null, + contentType: result.contentType ?? null, + denialReason: result.denialReason ?? null, + }), + }); + res.json(result); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, + error, + action: "issue.file_resource_resolve_denied", + }); + throw error; + } finally { + release?.(); + } + }); + + router.get("/issues/:issueId/file-resources/content", async (req, res) => { + const auditTarget = safeAuditTarget(req.query); + try { + assertBoard(req); + } catch (error) { + if (req.actor.type === "agent" && req.actor.companyId) { + await logDeniedAttempt({ + companyId: req.actor.companyId, + actor: getActorInfo(req), + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + }); + } + throw error; + } + const issue = await svc.getIssue(req.params.issueId); + const actor = getActorInfo(req); + try { + assertCompanyAccess(req, issue.companyId); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + }); + throw error; + } + let query: ReturnType; + try { + query = readQuery(req.query); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: safeAuditDisplayPath(req.query), + projectId: auditTarget.projectId, + workspaceId: auditTarget.workspaceId, + error, + }); + throw error; + } + let release: (() => void) | null = null; + try { + release = limiter.acquire(limiterKey(issue.companyId, actor.actorId, req.params.issueId)); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, + error, + }); + throw error; + } + try { + let result: WorkspaceFileContent | null = null; + try { + result = await svc.readContent(req.params.issueId, query, { issue }); + } catch (error) { + await logDeniedAttempt({ + companyId: issue.companyId, + actor, + issueId: req.params.issueId, + displayPath: query.path, + projectId: query.projectId, + workspaceId: query.workspaceId, + error, + }); + throw error; + } + + if (!result) throw unprocessable("Workspace file cannot be previewed"); + await logActivity(db, { + companyId: issue.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + action: "issue.file_resource_content_read", + entityType: "issue", + entityId: req.params.issueId, + agentId: actor.agentId, + runId: actor.runId, + details: activityDetails({ + outcome: "success", + workspaceKind: result.resource.workspaceKind, + workspaceId: result.resource.workspaceId, + projectId: result.resource.projectId ?? null, + projectName: result.resource.projectName ?? null, + displayPath: result.resource.displayPath, + byteSize: result.resource.byteSize ?? null, + contentType: result.resource.contentType ?? null, + }), + }); + + res.set("X-Content-Type-Options", "nosniff"); + res.json(result); + } finally { + release?.(); + } + }); + + return router; +} diff --git a/server/src/routes/index.ts b/server/src/routes/index.ts index 274af723..940047f2 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -6,6 +6,7 @@ export { agentRoutes } from "./agents.js"; export { projectRoutes } from "./projects.js"; export { issueRoutes } from "./issues.js"; export { issueTreeControlRoutes } from "./issue-tree-control.js"; +export { fileResourceRoutes, createFileResourceLimiter } from "./file-resources.js"; export { routineRoutes } from "./routines.js"; export { goalRoutes } from "./goals.js"; export { approvalRoutes } from "./approvals.js"; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4e599695..81b90061 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -126,6 +126,8 @@ import { secretProviderConfigDiscoveryPreviewSchema, remoteSecretImportPreviewSchema, remoteSecretImportSchema, + workspaceFileListQuerySchema, + workspaceFileResourceQuerySchema, } from "@paperclipai/shared"; type JsonSchema = Record; @@ -430,6 +432,10 @@ const responses = { description: "Internal server error", content: { "application/json": { schema: ErrorSchema } }, }, + tooManyRequests: { + description: "Too many requests", + content: { "application/json": { schema: ErrorSchema } }, + }, }; const jsonBody = (schema: z.ZodTypeAny) => ({ @@ -566,6 +572,9 @@ const BOARD_ONLY_OPERATIONS = new Set([ "GET /api/secrets/{id}/usage", "GET /api/secrets/{id}/access-events", "POST /api/health/dev-server/restart", + "GET /api/issues/{issueId}/file-resources/content", + "GET /api/issues/{issueId}/file-resources/list", + "GET /api/issues/{issueId}/file-resources/resolve", "POST /api/issues/{id}/interactions/{interactionId}/accept", "POST /api/issues/{id}/interactions/{interactionId}/reject", "POST /api/issues/{id}/interactions/{interactionId}/respond", @@ -1638,6 +1647,60 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/file-resources/list", + tags: ["issues"], + summary: "List workspace files for an issue", + request: { + params: z.object({ issueId: z.string() }), + query: workspaceFileListQuerySchema, + }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 404: r.notFound, + 422: r.unprocessable, + 429: r.tooManyRequests, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/file-resources/resolve", + tags: ["issues"], + summary: "Resolve an issue workspace file", + request: { + params: z.object({ issueId: z.string() }), + query: workspaceFileResourceQuerySchema, + }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 404: r.notFound, + 422: r.unprocessable, + 429: r.tooManyRequests, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/issues/{issueId}/file-resources/content", + tags: ["issues"], + summary: "Read issue workspace file content", + request: { + params: z.object({ issueId: z.string() }), + query: workspaceFileResourceQuerySchema, + }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 404: r.notFound, + 422: r.unprocessable, + 429: r.tooManyRequests, + }, +}); + registry.registerPath({ method: "get", path: "/api/issues/{id}/attachments", diff --git a/server/src/services/index.ts b/server/src/services/index.ts index c0ac9f47..7affa2e9 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -68,6 +68,7 @@ export { teamsCatalogService } from "./teams-catalog.js"; export { environmentService } from "./environments.js"; export { executionWorkspaceService } from "./execution-workspaces.js"; export { workspaceOperationService } from "./workspace-operations.js"; +export { workspaceFileResourceService } from "./workspace-file-resources.js"; export { workProductService } from "./work-products.js"; export { logActivity, type LogActivityInput } from "./activity-log.js"; export { notifyHireApproved, type NotifyHireApprovedInput } from "./hire-hook.js"; diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index e6c15745..170c8ca2 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -45,6 +45,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, + enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, enableCloudSync: parsed.data.enableCloudSync ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, @@ -58,6 +59,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: false, enableIssuePlanDecompositions: false, + enableExperimentalFileViewer: false, enableCloudSync: false, autoRestartDevServerWhenIdle: false, enableIssueGraphLivenessAutoRecovery: false, diff --git a/server/src/services/local-service-supervisor.ts b/server/src/services/local-service-supervisor.ts index 17738e72..86d270e3 100644 --- a/server/src/services/local-service-supervisor.ts +++ b/server/src/services/local-service-supervisor.ts @@ -249,12 +249,17 @@ async function isLikelyMatchingCommand(record: LocalServiceRegistryRecord) { export async function findAdoptableLocalService(input: { serviceKey: string; + profileKind?: string | null; + serviceName?: string | null; command?: string | null; cwd?: string | null; envFingerprint?: string | null; port?: number | null; + url?: string | null; }) { - const record = await readLocalServiceRegistryRecord(input.serviceKey); + const record = + await readLocalServiceRegistryRecord(input.serviceKey) + ?? await adoptLocalServiceFromPortOwner(input); if (!record) return null; if (!isPidAlive(record.pid)) { @@ -272,6 +277,59 @@ export async function findAdoptableLocalService(input: { return record; } +async function readProcessGroupId(pid: number) { + if (process.platform === "win32") return null; + try { + const { stdout } = await execFileAsync("ps", ["-o", "pgid=", "-p", String(pid)]); + const parsed = Number.parseInt(stdout.trim(), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; + } catch { + return null; + } +} + +async function adoptLocalServiceFromPortOwner(input: { + serviceKey: string; + profileKind?: string | null; + serviceName?: string | null; + command?: string | null; + cwd?: string | null; + envFingerprint?: string | null; + port?: number | null; + url?: string | null; +}) { + if (!input.port) return null; + const ownerPid = await readLocalServicePortOwner(input.port); + if (!ownerPid) return null; + + const processGroupId = await readProcessGroupId(ownerPid); + const pid = processGroupId && isPidAlive(processGroupId) ? processGroupId : ownerPid; + const now = new Date().toISOString(); + const record: LocalServiceRegistryRecord = { + version: 1, + serviceKey: input.serviceKey, + profileKind: input.profileKind ?? "workspace-runtime", + serviceName: input.serviceName ?? "service", + command: input.command ?? input.serviceName ?? "service", + cwd: input.cwd ?? process.cwd(), + envFingerprint: input.envFingerprint ?? "", + port: input.port, + url: input.url ?? null, + pid, + processGroupId: processGroupId ?? pid, + provider: "local_process", + runtimeServiceId: null, + reuseKey: input.envFingerprint ?? null, + startedAt: now, + lastSeenAt: now, + metadata: null, + }; + + if (!(await isLikelyMatchingCommand(record))) return null; + await writeLocalServiceRegistryRecord(record); + return record; +} + export async function touchLocalServiceRegistryRecord( serviceKey: string, patch?: Partial>, @@ -334,7 +392,7 @@ export async function terminateLocalService( export async function readLocalServicePortOwner(port: number) { if (!Number.isInteger(port) || port <= 0 || process.platform === "win32") return null; try { - const { stdout } = await execFileAsync("lsof", ["-nPiTCP", `:${port}`, "-sTCP:LISTEN", "-t"]); + const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"]); const firstPid = stdout .split("\n") .map((line) => Number.parseInt(line.trim(), 10)) diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts new file mode 100644 index 00000000..805f3a13 --- /dev/null +++ b/server/src/services/workspace-file-resources.ts @@ -0,0 +1,1403 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { and, desc, eq, inArray, isNull } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { executionWorkspaces, issues, projects, projectWorkspaces } from "@paperclipai/db"; +import type { + ResolvedWorkspaceResource, + WorkspaceFileContent, + WorkspaceFileListItem, + WorkspaceFileListMode, + WorkspaceFileListResponse, + WorkspaceFilePreviewKind, + WorkspaceFileSelector, + WorkspaceFileWorkspaceKind, +} from "@paperclipai/shared"; +import { HttpError, notFound, unprocessable } from "../errors.js"; + +export const WORKSPACE_FILE_TEXT_MAX_BYTES = 512 * 1024; +export const WORKSPACE_FILE_MEDIA_MAX_BYTES = 10 * 1024 * 1024; +export const WORKSPACE_FILE_LIST_DEFAULT_LIMIT = 25; +export const WORKSPACE_FILE_LIST_MAX_LIMIT = 100; +export const WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES = 5_000; +const MAX_RELATIVE_PATH_BYTES = 4096; +const TEXT_SNIFF_BYTES = 4096; +const MAX_LIST_DEPTH = 20; +const GIT_STATUS_MAX_BUFFER_BYTES = 1024 * 1024; +const execFileAsync = promisify(execFile); +const LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES = new Set(["local_path", "non_git_path", "git_repo", "git_worktree"]); + +const DENIED_SEGMENTS = new Set([ + ".git", + ".paperclip", + "node_modules", + ".pnpm-store", + ".yarn", + ".cache", + ".turbo", + ".next", + ".vite", + ".vercel", + "dist", + "build", + "coverage", + "runtime-services", + ".runtime", +]); + +const TEXT_EXTENSIONS = new Set([ + ".c", + ".cc", + ".conf", + ".cpp", + ".css", + ".csv", + ".go", + ".h", + ".html", + ".htm", + ".java", + ".js", + ".json", + ".jsx", + ".log", + ".md", + ".mjs", + ".py", + ".rb", + ".rs", + ".sh", + ".sql", + ".svg", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +]); + +const IMAGE_CONTENT_TYPES = new Map([ + [".gif", "image/gif"], + [".jpeg", "image/jpeg"], + [".jpg", "image/jpeg"], + [".png", "image/png"], + [".webp", "image/webp"], +]); + +const VIDEO_CONTENT_TYPES = new Map([ + [".m4v", "video/mp4"], + [".mov", "video/quicktime"], + [".mp4", "video/mp4"], + [".webm", "video/webm"], +]); + +type IssueRow = typeof issues.$inferSelect; +type ExecutionWorkspaceRow = typeof executionWorkspaces.$inferSelect; +type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; + +type WorkspaceCandidate = { + workspaceKind: WorkspaceFileWorkspaceKind; + workspaceId: string; + projectId?: string | null; + projectName?: string | null; + provider: string; + label: string; + rootPath: string | null; + remote: boolean; +}; + +type NormalizedPath = { + relativePath: string; + segments: string[]; +}; + +type LocalResolvedFile = { + resource: ResolvedWorkspaceResource; + realPath: string; +}; + +type LocalResolvedDirectory = { + resource: ResolvedWorkspaceResource; + realPath: string; + rootReal: string; +}; + +type AutoDiscovered = + | { state: "none" } + | { state: "one"; value: T } + | { state: "ambiguous"; count: number }; + +type WorkspaceFileListQueryInput = { + workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; + path?: string | null; + mode?: WorkspaceFileListMode | null; + q?: string | null; + limit?: number | null; + offset?: number | null; +}; + +type WorkspaceTargetInput = { + projectId?: string | null; + workspaceId?: string | null; +}; + +function previewCapForKind(kind: WorkspaceFilePreviewKind) { + return kind === "image" || kind === "video" || kind === "pdf" + ? WORKSPACE_FILE_MEDIA_MAX_BYTES + : WORKSPACE_FILE_TEXT_MAX_BYTES; +} + +function relativePathFromReal(rootReal: string, targetReal: string) { + return path.relative(rootReal, targetReal).split(path.sep).join(path.posix.sep); +} + +function isInsideRoot(rootReal: string, targetReal: string) { + const realRelative = path.relative(rootReal, targetReal); + return realRelative === "" || (!realRelative.startsWith("..") && !path.isAbsolute(realRelative)); +} + +function normalizeWorkspaceRelativePath(input: string): NormalizedPath { + const trimmed = input.trim(); + if (!trimmed) throw unprocessable("Workspace file path is required", { code: "invalid_path" }); + if (Buffer.byteLength(trimmed, "utf8") > MAX_RELATIVE_PATH_BYTES) { + throw unprocessable("Workspace file path is too long", { code: "invalid_path" }); + } + if (trimmed.includes("\0")) throw unprocessable("Workspace file path contains an invalid character", { code: "invalid_path" }); + if (/^file:\/\//i.test(trimmed)) throw unprocessable("File URLs are not supported", { code: "invalid_path" }); + if (/^[a-zA-Z]:/.test(trimmed)) throw unprocessable("Windows drive paths are not supported", { code: "invalid_path" }); + if (trimmed.startsWith("~")) throw unprocessable("Home-relative paths are not supported", { code: "invalid_path" }); + if (trimmed.includes("\\")) throw unprocessable("Workspace file paths must use forward slashes", { code: "invalid_path" }); + if (path.posix.isAbsolute(trimmed)) throw unprocessable("Workspace file path must be relative", { code: "invalid_path" }); + + const normalizedRaw = path.posix.normalize(trimmed); + const normalized = normalizedRaw.endsWith("/") ? normalizedRaw.replace(/\/+$/, "") : normalizedRaw; + if (normalized === "." || normalized === ".." || normalized.startsWith("../")) { + throw new HttpError(403, "Workspace file path is outside the workspace", { code: "outside_workspace" }); + } + + return { + relativePath: normalized, + segments: normalized.split("/").filter(Boolean), + }; +} + +function denyReasonForPathSegments(segments: string[]): string | null { + const lowerSegments = segments.map((segment) => segment.toLowerCase()); + if (lowerSegments.some((segment) => DENIED_SEGMENTS.has(segment))) return "denied_path_segment"; + + const fileName = lowerSegments.at(-1) ?? ""; + if (fileName === ".env" || fileName.startsWith(".env.")) return "denied_secret"; + if (fileName.endsWith(".pem") || fileName.endsWith(".key") || fileName.endsWith(".p12") || fileName.endsWith(".pfx")) { + return "denied_secret"; + } + if (["id_rsa", "id_ed25519", ".npmrc", ".pypirc", ".netrc", "kubeconfig"].includes(fileName)) return "denied_secret"; + if (lowerSegments.includes(".aws") || lowerSegments.includes(".ssh")) return "denied_secret"; + if (lowerSegments.length >= 2 && lowerSegments.at(-2) === ".docker" && fileName === "config.json") return "denied_secret"; + if (lowerSegments.length >= 2 && lowerSegments.at(-2) === ".kube" && fileName === "config") return "denied_secret"; + + return null; +} + +function throwIfDenied(segments: string[]) { + const denialReason = denyReasonForPathSegments(segments); + if (denialReason) { + throw new HttpError(403, "Workspace file path is denied by policy", { code: denialReason }); + } +} + +function shouldPruneSegments(segments: string[]) { + return denyReasonForPathSegments(segments) != null; +} + +function contentTypeForPath(filePath: string): string | null { + const ext = path.extname(filePath).toLowerCase(); + if (IMAGE_CONTENT_TYPES.has(ext)) return IMAGE_CONTENT_TYPES.get(ext) ?? null; + if (VIDEO_CONTENT_TYPES.has(ext)) return VIDEO_CONTENT_TYPES.get(ext) ?? null; + if (ext === ".pdf") return "application/pdf"; + if (ext === ".svg") return "image/svg+xml"; + if (ext === ".html" || ext === ".htm") return "text/html"; + if (TEXT_EXTENSIONS.has(ext)) return "text/plain; charset=utf-8"; + return null; +} + +function previewKindForKnownContentType(contentType: string | null): WorkspaceFilePreviewKind | null { + if (!contentType) return null; + if (contentType.startsWith("image/") && contentType !== "image/svg+xml") return "image"; + if (contentType.startsWith("video/")) return "video"; + if (contentType === "application/pdf") return "pdf"; + if (contentType === "text/html") return "unsupported"; + if (contentType === "image/svg+xml" || contentType.startsWith("text/")) return "text"; + return "unsupported"; +} + +function listItemFromStat(input: { + candidate: WorkspaceCandidate; + relativePath: string; + stat: { size: number; mtime: Date }; +}): WorkspaceFileListItem | null { + const contentType = contentTypeForPath(input.relativePath); + const previewKind = previewKindForKnownContentType(contentType); + if (!previewKind || previewKind === "unsupported") return null; + if (input.stat.size > previewCapForKind(previewKind)) return null; + + return { + kind: "file", + provider: input.candidate.provider, + title: path.posix.basename(input.relativePath), + relativePath: input.relativePath, + displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}` : input.relativePath, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, + contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"), + byteSize: input.stat.size, + modifiedAt: input.stat.mtime.toISOString(), + previewKind, + capabilities: { + preview: true, + download: false, + listChildren: false, + }, + }; +} + +function listItemFromDirectory(input: { + candidate: WorkspaceCandidate; + relativePath: string; + stat?: { mtime: Date }; +}): WorkspaceFileListItem { + return { + kind: "directory", + provider: input.candidate.provider, + title: path.posix.basename(input.relativePath), + relativePath: input.relativePath, + displayPath: input.candidate.projectName ? `${input.candidate.projectName} / ${input.relativePath}/` : `${input.relativePath}/`, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, + contentType: null, + byteSize: null, + modifiedAt: input.stat?.mtime.toISOString() ?? null, + previewKind: "unsupported", + capabilities: { + preview: false, + download: false, + listChildren: true, + }, + }; +} + +function looksLikeText(buffer: Buffer) { + if (buffer.length === 0) return true; + let controlBytes = 0; + for (const byte of buffer) { + if (byte === 0) return false; + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) controlBytes += 1; + } + return controlBytes / buffer.length < 0.02; +} + +async function sniffUnknownPreviewKind(realPath: string): Promise { + const handle = await fs.open(realPath, "r"); + try { + const buffer = Buffer.alloc(TEXT_SNIFF_BYTES); + const { bytesRead } = await handle.read(buffer, 0, TEXT_SNIFF_BYTES, 0); + return looksLikeText(buffer.subarray(0, bytesRead)) ? "text" : "unsupported"; + } finally { + await handle.close(); + } +} + +function remoteResource(candidate: WorkspaceCandidate, relativePath: string): ResolvedWorkspaceResource { + return { + kind: "remote_resource", + provider: candidate.provider || "remote_managed", + title: path.posix.basename(relativePath), + displayPath: candidate.projectName ? `${candidate.projectName} / ${relativePath}` : relativePath, + workspaceLabel: candidate.label, + workspaceKind: candidate.workspaceKind, + workspaceId: candidate.workspaceId, + projectId: candidate.projectId ?? null, + projectName: candidate.projectName ?? null, + contentType: null, + byteSize: null, + previewKind: "unsupported", + denialReason: "remote_workspace", + capabilities: { + preview: false, + download: false, + listChildren: false, + }, + }; +} + +function candidateFromExecutionWorkspace(row: ExecutionWorkspaceRow): WorkspaceCandidate { + const provider = row.providerType || row.strategyType || "local_fs"; + const rootPath = row.cwd || row.providerRef || null; + const remote = !["local_fs", "git_worktree"].includes(provider) || !rootPath || row.status !== "active" || row.closedAt != null; + return { + workspaceKind: "execution_workspace", + workspaceId: row.id, + provider, + label: row.name, + rootPath, + remote, + }; +} + +function candidateFromProjectWorkspace( + row: ProjectWorkspaceRow, + project?: { id: string; name: string } | null, +): WorkspaceCandidate { + const provider = row.sourceType === "local_path" ? "local_fs" : row.sourceType; + const rootPath = row.cwd ?? null; + const remote = !LOCAL_PROJECT_WORKSPACE_SOURCE_TYPES.has(row.sourceType) || !rootPath; + return { + workspaceKind: "project_workspace", + workspaceId: row.id, + projectId: project?.id ?? row.projectId, + projectName: project?.name ?? null, + provider, + label: row.name, + rootPath, + remote, + }; +} + +function isHttpStatus(error: unknown, status: number) { + return ( + error instanceof Error && + "status" in error && + (error as { status?: number }).status === status + ); +} + +function directoryResource(input: { + candidate: WorkspaceCandidate; + relativePath: string; +}): ResolvedWorkspaceResource { + return { + kind: "directory", + provider: input.candidate.provider, + title: path.posix.basename(input.relativePath), + displayPath: input.candidate.projectName + ? `${input.candidate.projectName} / ${input.relativePath}/` + : `${input.relativePath}/`, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, + contentType: null, + byteSize: null, + previewKind: "unsupported", + denialReason: null, + capabilities: { + preview: false, + download: false, + listChildren: true, + }, + }; +} + +async function statLocalCandidate(candidate: WorkspaceCandidate, normalized: NormalizedPath): Promise { + if (!candidate.rootPath) { + throw unprocessable("Workspace is not locally readable", { code: "remote_workspace" }); + } + throwIfDenied(normalized.segments); + + let rootReal: string; + try { + rootReal = await fs.realpath(candidate.rootPath); + } catch { + throw unprocessable("Workspace is not available on this host", { code: "workspace_unavailable" }); + } + + const targetLexical = path.resolve(rootReal, ...normalized.segments); + let targetReal: string; + try { + targetReal = await fs.realpath(targetLexical); + } catch { + throw notFound("Workspace file not found"); + } + + const realRelative = path.relative(rootReal, targetReal); + if (realRelative === "" || realRelative.startsWith("..") || path.isAbsolute(realRelative)) { + throw new HttpError(403, "Workspace file path is outside the workspace", { code: "outside_workspace" }); + } + throwIfDenied(relativePathFromReal(rootReal, targetReal).split("/").filter(Boolean)); + + const stat = await fs.stat(targetReal); + if (!stat.isFile()) { + throw unprocessable("Workspace file is not a regular file", { code: "not_regular_file" }); + } + + const contentType = contentTypeForPath(normalized.relativePath); + let previewKind = previewKindForKnownContentType(contentType); + if (!previewKind) { + previewKind = await sniffUnknownPreviewKind(targetReal); + } + + const cap = previewCapForKind(previewKind); + const tooLarge = stat.size > cap; + const unsupported = previewKind === "unsupported"; + const denialReason = tooLarge ? "too_large" : unsupported ? "unsupported_content" : null; + + return { + realPath: targetReal, + resource: { + kind: "file", + provider: candidate.provider, + title: path.posix.basename(normalized.relativePath), + displayPath: candidate.projectName ? `${candidate.projectName} / ${normalized.relativePath}` : normalized.relativePath, + workspaceLabel: candidate.label, + workspaceKind: candidate.workspaceKind, + workspaceId: candidate.workspaceId, + projectId: candidate.projectId ?? null, + projectName: candidate.projectName ?? null, + contentType: contentType ?? (previewKind === "text" ? "text/plain; charset=utf-8" : "application/octet-stream"), + byteSize: stat.size, + previewKind, + denialReason, + capabilities: { + preview: !tooLarge && !unsupported, + download: false, + listChildren: false, + }, + }, + }; +} + +async function statLocalDirectory(candidate: WorkspaceCandidate, normalized: NormalizedPath): Promise { + if (!candidate.rootPath) { + throw unprocessable("Workspace is not locally readable", { code: "remote_workspace" }); + } + throwIfDenied(normalized.segments); + + let rootReal: string; + try { + rootReal = await fs.realpath(candidate.rootPath); + } catch { + throw unprocessable("Workspace is not available on this host", { code: "workspace_unavailable" }); + } + + const targetLexical = path.resolve(rootReal, ...normalized.segments); + let targetReal: string; + try { + targetReal = await fs.realpath(targetLexical); + } catch { + throw notFound("Workspace folder not found"); + } + + if (!isInsideRoot(rootReal, targetReal)) { + throw new HttpError(403, "Workspace folder path is outside the workspace", { code: "outside_workspace" }); + } + throwIfDenied(relativePathFromReal(rootReal, targetReal).split("/").filter(Boolean)); + + const stat = await fs.stat(targetReal); + if (!stat.isDirectory()) { + throw unprocessable("Workspace path is not a directory", { code: "not_directory" }); + } + + return { + realPath: targetReal, + rootReal, + resource: directoryResource({ candidate, relativePath: normalized.relativePath }), + }; +} + +async function readStableFile(realPath: string, maxBytes: number) { + const handle = await fs.open(realPath, "r"); + try { + const before = await handle.stat(); + if (!before.isFile()) throw unprocessable("Workspace file is not a regular file", { code: "not_regular_file" }); + if (before.size > maxBytes) throw unprocessable("Workspace file is too large to preview", { code: "too_large" }); + const data = await handle.readFile(); + const after = await handle.stat(); + if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || after.mtimeMs !== before.mtimeMs) { + throw unprocessable("Workspace file changed while being read", { code: "file_changed" }); + } + return data; + } finally { + await handle.close(); + } +} + +function unavailableFileList(input: { + selector: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + path?: string | null; + q: string | null; + limit: number; + offset: number; + candidate?: WorkspaceCandidate | null; + reason: string; +}): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "unavailable", + unavailableReason: input.reason, + workspace: input.candidate + ? { + provider: input.candidate.provider, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, + } + : null, + query: { + workspace: input.selector, + mode: input.mode, + path: input.path ?? null, + q: input.q, + limit: input.limit, + offset: input.offset, + }, + items: [], + scannedCount: 0, + truncated: false, + }; +} + +function availableFileList(input: { + selector: WorkspaceFileSelector; + mode: WorkspaceFileListMode; + path?: string | null; + q: string | null; + limit: number; + offset: number; + candidate: WorkspaceCandidate; + items: WorkspaceFileListItem[]; + scannedCount: number; + truncated: boolean; +}): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "available", + workspace: { + provider: input.candidate.provider, + workspaceLabel: input.candidate.label, + workspaceKind: input.candidate.workspaceKind, + workspaceId: input.candidate.workspaceId, + projectId: input.candidate.projectId ?? null, + projectName: input.candidate.projectName ?? null, + }, + query: { + workspace: input.selector, + mode: input.mode, + path: input.path ?? null, + q: input.q, + limit: input.limit, + offset: input.offset, + }, + items: input.items, + scannedCount: input.scannedCount, + truncated: input.truncated, + }; +} + +function matchesSearch(relativePath: string, normalizedQuery: string | null) { + return !normalizedQuery || relativePath.toLowerCase().includes(normalizedQuery); +} + +async function listLocalFileCandidate(input: { + candidate: WorkspaceCandidate; + rootReal: string; + relativePath: string; + normalizedQuery: string | null; +}): Promise { + let normalized: NormalizedPath; + try { + normalized = normalizeWorkspaceRelativePath(input.relativePath); + } catch { + return null; + } + if (shouldPruneSegments(normalized.segments)) return null; + if (!matchesSearch(normalized.relativePath, input.normalizedQuery)) return null; + + const targetLexical = path.resolve(input.rootReal, ...normalized.segments); + let targetReal: string; + let stat; + try { + stat = await fs.lstat(targetLexical); + if (stat.isSymbolicLink() || !stat.isFile()) return null; + targetReal = await fs.realpath(targetLexical); + if (!isInsideRoot(input.rootReal, targetReal)) return null; + const realRelative = relativePathFromReal(input.rootReal, targetReal); + if (shouldPruneSegments(realRelative.split("/").filter(Boolean))) return null; + } catch { + return null; + } + return listItemFromStat({ candidate: input.candidate, relativePath: normalized.relativePath, stat }); +} + +async function listLocalDirectoryChildCandidate(input: { + candidate: WorkspaceCandidate; + rootReal: string; + parentReal: string; + relativePath: string; + entry: import("node:fs").Dirent; + normalizedQuery: string | null; +}): Promise { + let normalized: NormalizedPath; + try { + normalized = normalizeWorkspaceRelativePath(input.relativePath); + } catch { + return null; + } + if (shouldPruneSegments(normalized.segments)) return null; + if (!matchesSearch(normalized.relativePath, input.normalizedQuery)) return null; + if (input.entry.isSymbolicLink()) return null; + + if (input.entry.isDirectory()) { + const targetLexical = path.join(input.parentReal, input.entry.name); + try { + const stat = await fs.lstat(targetLexical); + const targetReal = await fs.realpath(targetLexical); + if (!isInsideRoot(input.rootReal, targetReal)) return null; + const realRelative = relativePathFromReal(input.rootReal, targetReal); + if (shouldPruneSegments(realRelative.split("/").filter(Boolean))) return null; + return listItemFromDirectory({ candidate: input.candidate, relativePath: normalized.relativePath, stat }); + } catch { + return null; + } + } + + if (!input.entry.isFile()) return null; + return listLocalFileCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + relativePath: input.relativePath, + normalizedQuery: input.normalizedQuery, + }); +} + +async function enumerateWorkspaceDirectoryChildren(input: { + candidate: WorkspaceCandidate; + rootReal: string; + startReal?: string; + startRelativePath?: string; + normalizedQuery: string | null; + limit: number; + offset: number; +}) { + let entries; + try { + entries = await fs.readdir(input.startReal ?? input.rootReal, { withFileTypes: true }); + } catch { + return { items: [], scannedCount: 0, truncated: false }; + } + + entries.sort((a, b) => { + if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1; + return a.name.localeCompare(b.name); + }); + + const items: WorkspaceFileListItem[] = []; + let scannedCount = 0; + let matchedCount = 0; + let truncated = false; + for (const entry of entries) { + scannedCount += 1; + if (scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { + truncated = true; + break; + } + if (entry.name.includes("\0") || entry.name.includes("/") || entry.name.includes("\\")) continue; + + const relativePath = input.startRelativePath ? `${input.startRelativePath}/${entry.name}` : entry.name; + const item = await listLocalDirectoryChildCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + parentReal: input.startReal ?? input.rootReal, + relativePath, + entry, + normalizedQuery: input.normalizedQuery, + }); + if (!item) continue; + + if (matchedCount < input.offset) { + matchedCount += 1; + continue; + } + if (items.length >= input.limit) { + truncated = true; + break; + } + matchedCount += 1; + items.push(item); + } + + return { items, scannedCount, truncated }; +} + +async function enumerateWorkspaceFiles(input: { + candidate: WorkspaceCandidate; + rootReal: string; + startReal?: string; + startRelativePath?: string; + mode: WorkspaceFileListMode; + normalizedQuery: string | null; + limit: number; + offset: number; +}) { + const dirs: Array<{ realPath: string; relativePath: string; depth: number }> = [ + { realPath: input.startReal ?? input.rootReal, relativePath: input.startRelativePath ?? "", depth: 0 }, + ]; + const items: WorkspaceFileListItem[] = []; + let scannedCount = 0; + let matchedCount = 0; + let truncated = false; + let hitScanCap = false; + + while (dirs.length > 0) { + const dir = dirs.shift()!; + let entries; + try { + entries = await fs.readdir(dir.realPath, { withFileTypes: true }); + } catch { + continue; + } + entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of entries) { + scannedCount += 1; + if (scannedCount > WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { + truncated = true; + hitScanCap = true; + break; + } + if (entry.name.includes("\0") || entry.name.includes("/") || entry.name.includes("\\")) continue; + + const relativePath = dir.relativePath ? `${dir.relativePath}/${entry.name}` : entry.name; + const segments = relativePath.split("/").filter(Boolean); + if (shouldPruneSegments(segments)) continue; + if (entry.isSymbolicLink()) continue; + + if (entry.isDirectory()) { + if (dir.depth >= MAX_LIST_DEPTH) { + truncated = true; + continue; + } + dirs.push({ + realPath: path.join(dir.realPath, entry.name), + relativePath, + depth: dir.depth + 1, + }); + continue; + } + if (!entry.isFile()) continue; + + const item = await listLocalFileCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + relativePath, + normalizedQuery: input.normalizedQuery, + }); + if (!item) continue; + + if (input.mode === "recent") { + items.push(item); + continue; + } + + if (matchedCount < input.offset) { + matchedCount += 1; + continue; + } + if (items.length >= input.limit) { + truncated = true; + break; + } + matchedCount += 1; + items.push(item); + } + if (hitScanCap || (truncated && input.mode !== "recent")) break; + } + + if (input.mode === "recent") { + items.sort((a, b) => (b.modifiedAt ?? "").localeCompare(a.modifiedAt ?? "") || a.displayPath.localeCompare(b.displayPath)); + const end = input.offset + input.limit; + truncated = truncated || items.length > end; + return { items: items.slice(input.offset, end), scannedCount, truncated }; + } + + return { items, scannedCount, truncated }; +} + +function parseGitStatusPaths(stdout: string) { + const tokens = stdout.split("\0").filter(Boolean); + const paths: string[] = []; + let hitScanCap = false; + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]!; + if (token.length < 4) continue; + const status = token.slice(0, 2); + const filePath = token.slice(3); + if (filePath) paths.push(filePath); + if (status.includes("R") || status.includes("C")) i += 1; + if (paths.length >= WORKSPACE_FILE_LIST_MAX_SCANNED_ENTRIES) { + hitScanCap = true; + break; + } + } + return { paths, hitScanCap }; +} + +async function listChangedWorkspaceFiles(input: { + candidate: WorkspaceCandidate; + rootReal: string; + normalizedQuery: string | null; + limit: number; + offset: number; +}) { + let stdout: string; + try { + const result = await execFileAsync( + "git", + ["-C", input.rootReal, "status", "--porcelain=v1", "-z", "--untracked-files=all"], + { maxBuffer: GIT_STATUS_MAX_BUFFER_BYTES }, + ); + stdout = result.stdout; + } catch { + return { unavailableReason: "changed_unavailable" as const }; + } + + const { paths, hitScanCap } = parseGitStatusPaths(stdout); + const matchedItems: WorkspaceFileListItem[] = []; + let scannedCount = 0; + for (const filePath of paths) { + scannedCount += 1; + const item = await listLocalFileCandidate({ + candidate: input.candidate, + rootReal: input.rootReal, + relativePath: filePath, + normalizedQuery: input.normalizedQuery, + }); + if (item) matchedItems.push(item); + } + matchedItems.sort((a, b) => a.displayPath.localeCompare(b.displayPath)); + const end = input.offset + input.limit; + return { + items: matchedItems.slice(input.offset, end), + scannedCount, + truncated: hitScanCap || matchedItems.length > end, + }; +} + +export function workspaceFileResourceService(db: Db) { + async function getIssue(issueId: string): Promise { + const [issue] = await db.select().from(issues).where(eq(issues.id, issueId)).limit(1); + if (!issue) throw notFound("Issue not found"); + return issue; + } + + async function targetProjectWorkspaceCandidate( + issue: IssueRow, + target: WorkspaceTargetInput, + ): Promise { + const projectId = target.projectId ?? null; + const workspaceId = target.workspaceId ?? null; + if (!projectId && !workspaceId) return null; + if (!projectId || !workspaceId) { + throw unprocessable("Workspace file target requires both projectId and workspaceId", { code: "invalid_target" }); + } + + const [project] = await db.select().from(projects).where(eq(projects.id, projectId)).limit(1); + const [workspace] = await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.id, workspaceId)).limit(1); + if (!project || !workspace) throw notFound("Project workspace not found"); + if (project.companyId !== issue.companyId || workspace.companyId !== issue.companyId) { + throw new HttpError(403, "Project workspace belongs to another company", { code: "cross_company_workspace" }); + } + if (workspace.projectId !== project.id) { + throw unprocessable("Workspace does not belong to the selected project", { code: "workspace_project_mismatch" }); + } + + return candidateFromProjectWorkspace(workspace, { id: project.id, name: project.name }); + } + + async function listCandidates( + issue: IssueRow, + selector: WorkspaceFileSelector, + target: WorkspaceTargetInput = {}, + ): Promise { + const explicitTarget = await targetProjectWorkspaceCandidate(issue, target); + if (explicitTarget) return [explicitTarget]; + + const candidates: WorkspaceCandidate[] = []; + if ((selector === "auto" || selector === "execution") && issue.projectId) { + const executionIds = [issue.executionWorkspaceId].filter((id): id is string => Boolean(id)); + let executionRows: ExecutionWorkspaceRow[] = []; + if (executionIds.length > 0) { + executionRows = await db.select().from(executionWorkspaces).where( + and( + eq(executionWorkspaces.companyId, issue.companyId), + inArray(executionWorkspaces.id, executionIds), + ), + ); + } + const sourceIssueIds = [issue.id, issue.parentId].filter((id): id is string => Boolean(id)); + if (sourceIssueIds.length > 0) { + const activeRows = await db.select().from(executionWorkspaces).where( + and( + eq(executionWorkspaces.companyId, issue.companyId), + eq(executionWorkspaces.projectId, issue.projectId), + inArray(executionWorkspaces.sourceIssueId, sourceIssueIds), + eq(executionWorkspaces.status, "active"), + isNull(executionWorkspaces.closedAt), + ), + ).orderBy(desc(executionWorkspaces.lastUsedAt)).limit(2); + executionRows = [...executionRows, ...activeRows]; + } + const seen = new Set(); + for (const row of executionRows) { + if (seen.has(row.id)) continue; + seen.add(row.id); + candidates.push(candidateFromExecutionWorkspace(row)); + } + } + + if ((selector === "auto" || selector === "project") && issue.projectId) { + if (issue.projectWorkspaceId) { + const rows = await db.select().from(projectWorkspaces).where( + and( + eq(projectWorkspaces.companyId, issue.companyId), + eq(projectWorkspaces.projectId, issue.projectId), + eq(projectWorkspaces.id, issue.projectWorkspaceId), + ), + ).limit(1); + if (rows[0]) candidates.push(candidateFromProjectWorkspace(rows[0])); + } + const primaryRows = await db.select().from(projectWorkspaces).where( + and( + eq(projectWorkspaces.companyId, issue.companyId), + eq(projectWorkspaces.projectId, issue.projectId), + eq(projectWorkspaces.isPrimary, true), + ), + ).limit(1); + if (primaryRows[0] && !candidates.some((candidate) => candidate.workspaceId === primaryRows[0]!.id)) { + candidates.push(candidateFromProjectWorkspace(primaryRows[0])); + } + } + + return candidates; + } + + async function sameCompanyProjectWorkspaceCandidates( + issue: IssueRow, + excludedWorkspaceIds: Set, + ): Promise { + const rows = await db.select({ + workspace: projectWorkspaces, + project: { + id: projects.id, + name: projects.name, + }, + }) + .from(projectWorkspaces) + .innerJoin(projects, eq(projectWorkspaces.projectId, projects.id)) + .where(and( + eq(projectWorkspaces.companyId, issue.companyId), + eq(projects.companyId, issue.companyId), + )) + .orderBy(desc(projectWorkspaces.isPrimary), desc(projectWorkspaces.updatedAt)); + + const candidates: WorkspaceCandidate[] = []; + const seen = new Set(excludedWorkspaceIds); + for (const row of rows) { + if (seen.has(row.workspace.id)) continue; + seen.add(row.workspace.id); + candidates.push(candidateFromProjectWorkspace(row.workspace, row.project)); + } + return candidates; + } + + async function discoverUniqueProjectWorkspaceMatch( + issue: IssueRow, + excludedWorkspaceIds: Set, + check: (candidate: WorkspaceCandidate) => Promise, + ): Promise> { + const candidates = await sameCompanyProjectWorkspaceCandidates(issue, excludedWorkspaceIds); + const matches: T[] = []; + for (const candidate of candidates) { + if (candidate.remote) continue; + try { + matches.push(await check(candidate)); + } catch (error) { + if (isHttpStatus(error, 404)) continue; + throw error; + } + if (matches.length > 1) return { state: "ambiguous", count: matches.length }; + } + if (matches.length === 1) return { state: "one", value: matches[0]! }; + return { state: "none" }; + } + + function throwAmbiguousWorkspacePath(count: number): never { + throw new HttpError(409, "Workspace path matched multiple project workspaces", { + code: "ambiguous_workspace_path", + matchCount: count, + }); + } + + async function resolve(issueId: string, input: { + path: string; + workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; + }, opts: { issue?: IssueRow } = {}): Promise { + const issue = opts.issue ?? await getIssue(issueId); + const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); + const isDirectoryRequest = input.path.trim().endsWith("/"); + const normalized = normalizeWorkspaceRelativePath(input.path); + const candidates = await listCandidates(issue, selector, input); + if (candidates.length === 0) { + throw unprocessable("No workspace is available for this issue", { code: "no_workspace" }); + } + + let lastNotFound: unknown = null; + for (const candidate of candidates) { + if (candidate.remote) { + if (explicitTarget || selector !== "auto") return remoteResource(candidate, normalized.relativePath); + continue; + } + try { + return isDirectoryRequest + ? (await statLocalDirectory(candidate, normalized)).resource + : (await statLocalCandidate(candidate, normalized)).resource; + } catch (error) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { + lastNotFound = error; + continue; + } + throw error; + } + } + + if (lastNotFound && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => isDirectoryRequest + ? (await statLocalDirectory(candidate, normalized)).resource + : (await statLocalCandidate(candidate, normalized)).resource, + ); + if (discovered.state === "one") return discovered.value; + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + } + + if (lastNotFound) throw lastNotFound; + throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" }); + } + + async function list( + issueId: string, + input: WorkspaceFileListQueryInput = {}, + opts: { issue?: IssueRow } = {}, + ): Promise { + const issue = opts.issue ?? await getIssue(issueId); + const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); + const normalizedPath = input.path?.trim() ? normalizeWorkspaceRelativePath(input.path) : null; + const mode = normalizedPath ? "all" : input.mode ?? "all"; + const limit = Math.min( + WORKSPACE_FILE_LIST_MAX_LIMIT, + Math.max(1, Math.floor(input.limit ?? WORKSPACE_FILE_LIST_DEFAULT_LIMIT)), + ); + const offset = Math.max(0, Math.floor(input.offset ?? 0)); + const q = input.q?.trim() || null; + const normalizedQuery = q?.toLowerCase() ?? null; + const candidates = await listCandidates(issue, selector, input); + if (candidates.length === 0) { + return unavailableFileList({ selector, mode, path: normalizedPath?.relativePath ?? null, q, limit, offset, reason: "no_workspace" }); + } + + let firstUnavailable: { candidate: WorkspaceCandidate; reason: string } | null = null; + let lastNotFound: unknown = null; + for (const candidate of candidates) { + if (candidate.remote) { + firstUnavailable ??= { candidate, reason: "remote_workspace" }; + if (explicitTarget || selector !== "auto") { + return unavailableFileList({ + selector, + mode, + path: normalizedPath?.relativePath ?? null, + q, + limit, + offset, + candidate, + reason: "remote_workspace", + }); + } + continue; + } + + let rootReal: string; + let startReal: string | undefined; + let startRelativePath: string | undefined; + try { + rootReal = await fs.realpath(candidate.rootPath!); + if (normalizedPath) { + const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath); + rootReal = resolvedDirectory.rootReal; + startReal = resolvedDirectory.realPath; + startRelativePath = normalizedPath.relativePath; + } + } catch (error) { + if (normalizedPath) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { + lastNotFound = error; + continue; + } + throw error; + } + firstUnavailable ??= { candidate, reason: "workspace_unavailable" }; + if (explicitTarget || selector !== "auto") { + return unavailableFileList({ + selector, + mode, + path: null, + q, + limit, + offset, + candidate, + reason: "workspace_unavailable", + }); + } + continue; + } + + if (mode === "changed") { + const changed = await listChangedWorkspaceFiles({ candidate, rootReal, normalizedQuery, limit, offset }); + if ("unavailableReason" in changed) { + const reason = changed.unavailableReason ?? "changed_unavailable"; + firstUnavailable ??= { candidate, reason }; + if (explicitTarget || selector !== "auto") { + return unavailableFileList({ + selector, + mode, + path: normalizedPath?.relativePath ?? null, + q, + limit, + offset, + candidate, + reason, + }); + } + continue; + } + return availableFileList({ + selector, + mode, + path: normalizedPath?.relativePath ?? null, + q, + limit, + offset, + candidate, + items: changed.items, + scannedCount: changed.scannedCount, + truncated: changed.truncated, + }); + } + + const listed = mode === "all" && !normalizedQuery + ? await enumerateWorkspaceDirectoryChildren({ + candidate, + rootReal, + startReal, + startRelativePath, + normalizedQuery, + limit, + offset, + }) + : await enumerateWorkspaceFiles({ + candidate, + rootReal, + startReal, + startRelativePath, + mode, + normalizedQuery, + limit, + offset, + }); + return availableFileList({ + selector, + mode, + path: normalizedPath?.relativePath ?? null, + q, + limit, + offset, + candidate, + items: listed.items, + scannedCount: listed.scannedCount, + truncated: listed.truncated, + }); + } + + if (lastNotFound && normalizedPath && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => { + const resolvedDirectory = await statLocalDirectory(candidate, normalizedPath); + return { + candidate, + rootReal: resolvedDirectory.rootReal, + startReal: resolvedDirectory.realPath, + startRelativePath: normalizedPath.relativePath, + }; + }, + ); + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + if (discovered.state === "one") { + const listed = mode === "all" && !normalizedQuery + ? await enumerateWorkspaceDirectoryChildren({ + candidate: discovered.value.candidate, + rootReal: discovered.value.rootReal, + startReal: discovered.value.startReal, + startRelativePath: discovered.value.startRelativePath, + normalizedQuery, + limit, + offset, + }) + : await enumerateWorkspaceFiles({ + candidate: discovered.value.candidate, + rootReal: discovered.value.rootReal, + startReal: discovered.value.startReal, + startRelativePath: discovered.value.startRelativePath, + mode, + normalizedQuery, + limit, + offset, + }); + return availableFileList({ + selector, + mode, + path: normalizedPath.relativePath, + q, + limit, + offset, + candidate: discovered.value.candidate, + items: listed.items, + scannedCount: listed.scannedCount, + truncated: listed.truncated, + }); + } + } + + return unavailableFileList({ + selector, + mode, + path: normalizedPath?.relativePath ?? null, + q, + limit, + offset, + candidate: firstUnavailable?.candidate ?? null, + reason: firstUnavailable?.reason ?? "no_local_workspace", + }); + } + + async function readContent(issueId: string, input: { + path: string; + workspace?: WorkspaceFileSelector | null; + projectId?: string | null; + workspaceId?: string | null; + }, opts: { issue?: IssueRow } = {}): Promise { + const issue = opts.issue ?? await getIssue(issueId); + const selector = input.workspace ?? "auto"; + const explicitTarget = Boolean(input.projectId || input.workspaceId); + const normalized = normalizeWorkspaceRelativePath(input.path); + const candidates = await listCandidates(issue, selector, input); + if (candidates.length === 0) { + throw unprocessable("No workspace is available for this issue", { code: "no_workspace" }); + } + + let lastNotFound: unknown = null; + for (const candidate of candidates) { + if (candidate.remote) { + if (explicitTarget || selector !== "auto") { + throw unprocessable("Remote workspaces cannot be previewed by the server", { code: "remote_workspace" }); + } + continue; + } + let resolved: LocalResolvedFile; + try { + resolved = await statLocalCandidate(candidate, normalized); + } catch (error) { + if (!explicitTarget && selector === "auto" && isHttpStatus(error, 404)) { + lastNotFound = error; + continue; + } + throw error; + } + + if (!resolved.resource.capabilities.preview) { + throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" }); + } + const cap = previewCapForKind(resolved.resource.previewKind); + const data = await readStableFile(resolved.realPath, cap); + if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) { + throw unprocessable("Workspace file is not a text file", { code: "binary_content" }); + } + + return { + resource: resolved.resource, + content: { + encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64", + data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"), + }, + }; + } + + if (lastNotFound && !explicitTarget && selector === "auto") { + const discovered = await discoverUniqueProjectWorkspaceMatch( + issue, + new Set(candidates.map((candidate) => candidate.workspaceId)), + async (candidate) => statLocalCandidate(candidate, normalized), + ); + if (discovered.state === "ambiguous") throwAmbiguousWorkspacePath(discovered.count); + if (discovered.state === "one") { + const resolved = discovered.value; + if (!resolved.resource.capabilities.preview) { + throw unprocessable("Workspace file cannot be previewed", { code: resolved.resource.denialReason ?? "unsupported_content" }); + } + const cap = previewCapForKind(resolved.resource.previewKind); + const data = await readStableFile(resolved.realPath, cap); + if (resolved.resource.previewKind === "text" && !looksLikeText(data.subarray(0, Math.min(data.length, TEXT_SNIFF_BYTES)))) { + throw unprocessable("Workspace file is not a text file", { code: "binary_content" }); + } + + return { + resource: resolved.resource, + content: { + encoding: resolved.resource.previewKind === "text" ? "utf8" : "base64", + data: resolved.resource.previewKind === "text" ? data.toString("utf8") : data.toString("base64"), + }, + }; + } + } + + if (lastNotFound) throw lastNotFound; + throw unprocessable("No local-readable workspace is available for this issue", { code: "no_local_workspace" }); + } + + return { + getIssue, + list, + resolve, + readContent, + }; +} diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index 5d22c743..dc63868a 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -1901,10 +1901,44 @@ async function waitForReadiness(input: { throw new Error(`Readiness check failed for ${input.url}: ${lastError}`); } -async function isRuntimeServiceUrlHealthy(url: string | null) { - if (!url) return true; +function isPaperclipDevRuntimeService(input: { serviceName?: string | null; command?: string | null }) { + const serviceName = (input.serviceName ?? "").trim().toLowerCase(); + const command = (input.command ?? "").trim().toLowerCase(); + return ( + serviceName === "paperclip-dev" + || serviceName === "paperclip-dev-once" + || (command.includes("dev:once") && command.includes("tailscale-auth")) + ); +} + +function resolveRuntimeServiceHealthUrl( + url: string | null, + input?: { serviceName?: string | null; command?: string | null }, +) { + if (!url || !isPaperclipDevRuntimeService(input ?? {})) return url; try { - const response = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + const parsed = new URL(url); + if (parsed.pathname === "/" || parsed.pathname === "") { + parsed.pathname = "/api/health"; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString(); + } + } catch { + return url; + } + return url; +} + +async function isRuntimeServiceUrlHealthy( + url: string | null, + input?: { serviceName?: string | null; command?: string | null }, +) { + if (!url) return true; + const healthUrl = resolveRuntimeServiceHealthUrl(url, input); + if (!healthUrl) return true; + try { + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }); return response.ok; } catch { return false; @@ -2123,12 +2157,11 @@ async function startLocalRuntimeService(input: { companyId: input.agent.companyId, reuseKey: input.reuseKey, }); - const reusableStoppedPort = - asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port - ? (await readLocalServicePortOwner(stoppedReuseCandidate.port)) - ? null - : stoppedReuseCandidate.port - : null; + let reusableStoppedPort: number | null = null; + if (asString(portConfig.type, "") === "auto" && stoppedReuseCandidate?.port) { + const ownerPid = await readLocalServicePortOwner(stoppedReuseCandidate.port); + reusableStoppedPort = ownerPid ? null : stoppedReuseCandidate.port; + } const port = asString(portConfig.type, "") === "auto" ? (reusableStoppedPort ?? await allocatePort()) @@ -2181,48 +2214,57 @@ async function startLocalRuntimeService(input: { }); const adoptedRecord = await findAdoptableLocalService({ serviceKey, + profileKind: "workspace-runtime", + serviceName, command, cwd: serviceCwd, envFingerprint: serviceIdentityFingerprint, - port: identityPort, + port: port ?? identityPort, + url, }); if (adoptedRecord) { - return { - id: adoptedRecord.runtimeServiceId ?? randomUUID(), - companyId: input.agent.companyId, - projectId: input.workspace.projectId, - projectWorkspaceId: input.workspace.workspaceId, - executionWorkspaceId: input.executionWorkspaceId ?? null, - issueId: input.issue?.id ?? null, - serviceName, - status: "running", - lifecycle, - scopeType: input.scopeType, - scopeId: input.scopeId, - reuseKey: input.reuseKey, - command, - cwd: serviceCwd, - port: adoptedRecord.port ?? port, - url: adoptedRecord.url ?? url, - provider: "local_process", - providerRef: String(adoptedRecord.pid), - ownerAgentId: input.agent.id ?? null, - startedByRunId, - lastUsedAt: new Date().toISOString(), - startedAt: adoptedRecord.startedAt, - stoppedAt: null, - stopPolicy, - healthStatus: "healthy", - reused: true, - db: input.db, - child: null, - leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), - idleTimer: null, - envFingerprint, - serviceKey, - profileKind: "workspace-runtime", - processGroupId: adoptedRecord.processGroupId ?? null, - }; + const adoptedUrl = adoptedRecord.url ?? url; + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName, command }))) { + await terminateLocalService(adoptedRecord); + await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); + } else { + return { + id: adoptedRecord.runtimeServiceId ?? randomUUID(), + companyId: input.agent.companyId, + projectId: input.workspace.projectId, + projectWorkspaceId: input.workspace.workspaceId, + executionWorkspaceId: input.executionWorkspaceId ?? null, + issueId: input.issue?.id ?? null, + serviceName, + status: "running", + lifecycle, + scopeType: input.scopeType, + scopeId: input.scopeId, + reuseKey: input.reuseKey, + command, + cwd: serviceCwd, + port: adoptedRecord.port ?? port, + url: adoptedRecord.url ?? url, + provider: "local_process", + providerRef: String(adoptedRecord.pid), + ownerAgentId: input.agent.id ?? null, + startedByRunId, + lastUsedAt: new Date().toISOString(), + startedAt: adoptedRecord.startedAt, + stoppedAt: null, + stopPolicy, + healthStatus: "healthy", + reused: true, + db: input.db, + child: null, + leaseRunIds: leaseRunId ? new Set([leaseRunId]) : new Set(), + idleTimer: null, + envFingerprint, + serviceKey, + profileKind: "workspace-runtime", + processGroupId: adoptedRecord.processGroupId ?? null, + }; + } } if (identityPort) { const ownerPid = await readLocalServicePortOwner(identityPort); @@ -2840,13 +2882,38 @@ export async function reconcilePersistedRuntimeServicesOnStartup(db: Db) { let adopted = 0; let stopped = 0; for (const row of rows) { - const adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({ + let adoptedRecord = await findLocalServiceRegistryRecordByRuntimeServiceId({ runtimeServiceId: row.id, profileKind: "workspace-runtime", }); + if (!adoptedRecord && row.command && row.cwd) { + adoptedRecord = await findAdoptableLocalService({ + serviceKey: createLocalServiceKey({ + profileKind: "workspace-runtime", + serviceName: row.serviceName, + cwd: row.cwd, + command: row.command, + envFingerprint: row.reuseKey ?? "", + port: null, + scope: { + scopeType: row.scopeType as RuntimeServiceRecord["scopeType"], + scopeId: row.scopeId ?? null, + executionWorkspaceId: row.executionWorkspaceId ?? null, + reuseKey: row.reuseKey ?? null, + }, + }), + profileKind: "workspace-runtime", + serviceName: row.serviceName, + command: row.command, + cwd: row.cwd, + envFingerprint: row.reuseKey ?? "", + port: row.port ?? null, + url: row.url ?? null, + }); + } if (adoptedRecord) { const adoptedUrl = adoptedRecord.url ?? row.url ?? null; - if (!(await isRuntimeServiceUrlHealthy(adoptedUrl))) { + if (!(await isRuntimeServiceUrlHealthy(adoptedUrl, { serviceName: row.serviceName, command: row.command }))) { await removeLocalServiceRegistryRecord(adoptedRecord.serviceKey); } else { const record: RuntimeServiceRecord = { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 4282dda3..7f9e2d6b 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -100,7 +100,9 @@ If `currentParticipant` does not match you, do not try to advance the stage — ### Generated Artifacts and Work Products -When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. +When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition and create an artifact work product. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. + +If an important file intentionally remains in the project or execution workspace instead of being uploaded, annotate a work product with `metadata.resourceRef.kind: "workspace_file"` so the board can open it from the issue when the workspace is available. Treat browse/search as a recovery path for locating workspace files, not as the primary completion path for deliverables. For technical upload instructions, read `references/artifacts.md`. diff --git a/skills/paperclip/references/artifacts.md b/skills/paperclip/references/artifacts.md index 76aee712..03855b17 100644 --- a/skills/paperclip/references/artifacts.md +++ b/skills/paperclip/references/artifacts.md @@ -1,6 +1,6 @@ # Generated Artifacts and Work Products -When work produces a user-inspectable file, upload it to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. +When work produces a user-inspectable file, upload true deliverables to the current issue before final disposition. Local filesystem paths are not enough because board users, reviewers, and cloud operators may not have access to the agent workspace. Use the helper bundled with this skill. From an installed `paperclip` skill directory, the helper lives at `scripts/paperclip-upload-artifact.sh`: @@ -12,6 +12,54 @@ scripts/paperclip-upload-artifact.sh path/to/output.webm \ The helper uses `PAPERCLIP_API_URL`, `PAPERCLIP_API_KEY`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_TASK_ID`, and `PAPERCLIP_RUN_ID`. It uploads the file as an issue attachment, creates an attachment-backed artifact work product by default, and prints issue-safe markdown links for your final comment. +## Workspace-Only File References + +Use a workspace-only reference only when the file should stay in the project or +execution workspace, such as a source file, committed report, generated index, +or other file whose value is tied to the checkout. This is not a substitute for +uploading a deliverable file that a board user should be able to inspect outside +the workspace. + +Annotate the work product with `metadata.resourceRef`: + +```json +{ + "type": "document", + "provider": "workspace", + "title": "Regression test plan", + "status": "ready_for_review", + "reviewState": "needs_board_review", + "summary": "Markdown plan committed in the execution workspace.", + "metadata": { + "resourceRef": { + "kind": "workspace_file", + "issueId": "", + "workspaceKind": "execution_workspace", + "workspaceId": "", + "relativePath": "doc/plans/regression-test-plan.md", + "line": 1, + "displayPath": "doc/plans/regression-test-plan.md" + } + } +} +``` + +`workspaceKind` is `execution_workspace` for the current issue checkout or +`project_workspace` for a shared project workspace. `line` and `column` are +optional positive integers. `relativePath` must be relative to the selected +workspace root; do not use host-local absolute paths in `resourceRef`. + +Create the work product with: + +```bash +curl -sS -X POST \ + "$PAPERCLIP_API_URL/api/issues/$PAPERCLIP_TASK_ID/work-products" \ + -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + -H "X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID" \ + -H "Content-Type: application/json" \ + --data-binary @workspace-file-work-product.json +``` + If the helper is unavailable, use the Paperclip API directly: ```bash @@ -41,4 +89,10 @@ curl -sS -X POST \ }' ``` -In your final issue comment, link the uploaded attachment or work product and describe what it contains. Do not leave artifact-producing work `in_progress` with only a local path or a `Remaining` note. +In your final issue comment, link the uploaded attachment or work product and +describe what it contains. If the output is workspace-only, name the work +product and the relative path that was recorded in `metadata.resourceRef`. +Browse/search is the fallback for recovering a workspace file when the issue +chip or link cannot open it; it is not the preferred deliverable path. Do not +leave artifact-producing work `in_progress` with only a local path or a +`Remaining` note. diff --git a/ui/src/api/file-resources.ts b/ui/src/api/file-resources.ts new file mode 100644 index 00000000..affc5f77 --- /dev/null +++ b/ui/src/api/file-resources.ts @@ -0,0 +1,65 @@ +import type { + ResolvedWorkspaceResource, + WorkspaceFileContent, + WorkspaceFileListMode, + WorkspaceFileListResponse, + WorkspaceFileSelector, +} from "@paperclipai/shared"; +import { api } from "./client"; + +export interface FileResourceQuery { + path: string; + workspace?: WorkspaceFileSelector; + projectId?: string | null; + workspaceId?: string | null; +} + +export interface FileResourceListQuery { + workspace?: WorkspaceFileSelector; + projectId?: string | null; + workspaceId?: string | null; + path?: string | null; + mode?: WorkspaceFileListMode; + q?: string | null; + limit?: number; + offset?: number; +} + +function buildQuery(query: FileResourceQuery | FileResourceListQuery): string { + const params = new URLSearchParams(); + if (query.projectId && query.workspaceId) { + params.set("projectId", query.projectId); + params.set("workspaceId", query.workspaceId); + } + if ("path" in query && query.path) params.set("path", query.path); + if (query.workspace && query.workspace !== "auto") { + params.set("workspace", query.workspace); + } + if ("mode" in query && query.mode && query.mode !== "all") params.set("mode", query.mode); + if ("q" in query && query.q) params.set("q", query.q); + if ("limit" in query && query.limit) params.set("limit", String(query.limit)); + if ("offset" in query && query.offset) params.set("offset", String(query.offset)); + return params.toString(); +} + +export const fileResourcesApi = { + list(issueId: string, query: FileResourceListQuery = {}): Promise { + const search = buildQuery(query); + const suffix = search ? `?${search}` : ""; + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/list${suffix}`, + ); + }, + + resolve(issueId: string, query: FileResourceQuery): Promise { + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/resolve?${buildQuery(query)}`, + ); + }, + + content(issueId: string, query: FileResourceQuery): Promise { + return api.get( + `/issues/${encodeURIComponent(issueId)}/file-resources/content?${buildQuery(query)}`, + ); + }, +}; diff --git a/ui/src/components/ArtifactFileChip.tsx b/ui/src/components/ArtifactFileChip.tsx new file mode 100644 index 00000000..49f2987c --- /dev/null +++ b/ui/src/components/ArtifactFileChip.tsx @@ -0,0 +1,103 @@ +import type { MouseEvent, ReactNode } from "react"; +import { FileCode2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { WorkspaceFileRef } from "@paperclipai/shared"; +import { useFileViewer } from "@/context/FileViewerContext"; + +export interface ArtifactFileChipProps { + workspaceFileRef: WorkspaceFileRef; + /** Override the rendered label. Defaults to the display path. */ + label?: ReactNode; + className?: string; + /** Optional override if the consumer wants to customize activation. */ + onOpen?: (ref: WorkspaceFileRef) => void; + showIcon?: boolean; + title?: string; +} + +function artifactFileDisplay(ref: WorkspaceFileRef) { + if (!ref.projectName) return ref.displayPath; + const prefix = `${ref.projectName} / `; + return ref.displayPath.startsWith(prefix) ? ref.displayPath : `${prefix}${ref.displayPath}`; +} + +export function ArtifactFileChip({ + workspaceFileRef, + label, + className, + onOpen, + showIcon = true, + title, +}: ArtifactFileChipProps) { + const viewer = useFileViewer(); + const display = typeof label !== "undefined" ? label : artifactFileDisplay(workspaceFileRef); + const canOpen = !!(onOpen || viewer); + const lineSuffix = workspaceFileRef.line + ? ` line ${workspaceFileRef.line}${workspaceFileRef.column ? ` column ${workspaceFileRef.column}` : ""}` + : ""; + const ariaLabel = canOpen + ? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer` + : `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`; + const tooltip = title ?? (canOpen + ? `Open ${workspaceFileRef.displayPath}${lineSuffix} in the file viewer` + : `Workspace file ${workspaceFileRef.displayPath}${lineSuffix}`); + + const classNames = cn( + "paperclip-artifact-file-chip inline-flex items-center gap-1 rounded-sm border border-border bg-muted/60 px-1.5 py-0.5 font-mono text-xs leading-tight text-foreground/90 align-baseline no-underline hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", + canOpen ? "cursor-pointer" : null, + className, + ); + const content = ( + <> + {showIcon ? ), a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => { + const workspaceFileRef = parseWorkspaceFileHref(href); + if (workspaceFileRef) { + return ( + + ); + } + const dataProps = anchorProps as Record; const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true"; if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) { diff --git a/ui/src/components/WorkspaceFileBrowser.test.tsx b/ui/src/components/WorkspaceFileBrowser.test.tsx new file mode 100644 index 00000000..739e7024 --- /dev/null +++ b/ui/src/components/WorkspaceFileBrowser.test.tsx @@ -0,0 +1,826 @@ +// @vitest-environment jsdom + +import type { ComponentProps } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import type { Root } from "react-dom/client"; +import type { Project, ProjectWorkspace, WorkspaceFileListDirectoryItem, WorkspaceFileListFileItem, WorkspaceFileListItem, WorkspaceFileListResponse } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WorkspaceFileBrowser, describeUnavailable } from "./WorkspaceFileBrowser"; + +function act(callback: () => void | Promise) { + let result: void | Promise | undefined; + flushSync(() => { + result = callback(); + }); + return result; +} + +async function waitForExpectation(assertion: () => void) { + let lastError: unknown; + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => window.setTimeout(resolve, 0)); + } + } + throw lastError; +} + +const useQueryMock = vi.fn(); +const LIST_LIMIT = 100; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useQuery: (options: unknown) => useQueryMock(options), + useQueries: ({ queries }: { queries: unknown[] }) => queries.map((options) => useQueryMock(options)), + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +function createItem(overrides: Partial = {}): WorkspaceFileListFileItem { + return { + kind: "file", + provider: "git_worktree", + title: "IssueDetail.tsx", + relativePath: "ui/src/pages/IssueDetail.tsx", + displayPath: "ui/src/pages/IssueDetail.tsx", + workspaceLabel: "Isolated workspace", + workspaceKind: "execution_workspace", + workspaceId: "ws-1", + contentType: "text/plain; charset=utf-8", + byteSize: 2048, + modifiedAt: new Date(Date.now() - 120_000).toISOString(), + previewKind: "text", + capabilities: { preview: true, download: false, listChildren: false }, + ...overrides, + }; +} + +function createDirectoryItem(overrides: Partial = {}): WorkspaceFileListDirectoryItem { + return { + kind: "directory", + provider: "git_worktree", + title: "src", + relativePath: "ui/src", + displayPath: "ui/src/", + workspaceLabel: "Isolated workspace", + workspaceKind: "execution_workspace", + workspaceId: "ws-1", + contentType: null, + byteSize: null, + modifiedAt: null, + previewKind: "unsupported", + capabilities: { preview: false, download: false, listChildren: true }, + ...overrides, + }; +} + +function availableResponse(items: WorkspaceFileListItem[], truncated = false): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "available", + workspace: { + provider: "git_worktree", + workspaceLabel: "Isolated workspace", + workspaceKind: "execution_workspace", + workspaceId: "ws-1", + }, + query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 }, + items, + scannedCount: items.length, + truncated, + }; +} + +function availableAllResponse( + items: WorkspaceFileListItem[], + path: string | null, + truncated = false, + offset = 0, +): WorkspaceFileListResponse { + const response = availableResponse(items, truncated); + response.query = { ...response.query, mode: "all", path, offset }; + return response; +} + +function createWorkspace(overrides: Partial = {}): ProjectWorkspace { + return { + id: "workspace-content", + companyId: "company-1", + projectId: "project-content", + name: "Paperclip Content", + sourceType: "local_path", + cwd: "/srv/paperclip/home/paperclipai/paperclip-content", + repoUrl: null, + repoRef: null, + defaultRef: null, + visibility: "default", + setupCommand: null, + cleanupCommand: null, + remoteProvider: null, + remoteWorkspaceRef: null, + sharedWorkspaceKey: null, + metadata: null, + runtimeConfig: null, + isPrimary: true, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + updatedAt: new Date("2026-06-08T00:00:00.000Z"), + ...overrides, + }; +} + +function createProject(overrides: Partial = {}): Project { + const workspace = createWorkspace(); + return { + id: "project-content", + companyId: "company-1", + urlKey: "paperclip-content", + goalId: null, + goalIds: [], + goals: [], + name: "Paperclip Content", + description: null, + status: "in_progress", + leadAgentId: null, + targetDate: null, + color: null, + icon: null, + env: null, + pauseReason: null, + pausedAt: null, + executionWorkspacePolicy: null, + codebase: { + workspaceId: workspace.id, + repoUrl: null, + repoRef: null, + defaultRef: null, + repoName: null, + localFolder: workspace.cwd, + managedFolder: "", + effectiveLocalFolder: workspace.cwd ?? "", + origin: "local_folder", + }, + workspaces: [workspace], + primaryWorkspace: workspace, + archivedAt: null, + createdAt: new Date("2026-06-08T00:00:00.000Z"), + updatedAt: new Date("2026-06-08T00:00:00.000Z"), + ...overrides, + }; +} + +function unavailableResponse(reason: string): WorkspaceFileListResponse { + return { + kind: "workspace_file_list", + state: "unavailable", + unavailableReason: reason, + workspace: null, + query: { workspace: "auto", mode: "changed", q: null, limit: 100, offset: 0 }, + items: [], + scannedCount: 0, + truncated: false, + }; +} + +function ok(data: T) { + return { data, isFetching: false, isError: false, error: null, refetch: vi.fn() }; +} + +describe("WorkspaceFileBrowser", () => { + let container: HTMLDivElement; + const roots: Root[] = []; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + useQueryMock.mockReset(); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + container.remove(); + vi.restoreAllMocks(); + }); + + function renderBrowser(onOpen = vi.fn(), props: Partial> = {}) { + const root = createRoot(container); + roots.push(root); + act(() => { + root.render(); + }); + return { root, onOpen }; + } + + it("renders the Recently changed files as a tree and opens a row with its relative path", () => { + useQueryMock.mockReturnValue( + ok(availableResponse([createItem(), createItem({ relativePath: "README.md", displayPath: "README.md" })])), + ); + + const { onOpen } = renderBrowser(); + + expect(container.querySelector('[role="tree"]')).not.toBeNull(); + expect(container.textContent).toContain("Isolated workspace"); + expect(container.textContent).not.toContain("Recently changed"); + expect(container.textContent).not.toContain("From Isolated workspace"); + + const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx", + ); + expect(option).not.toBeUndefined(); + + act(() => { + option!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onOpen).toHaveBeenCalledWith({ + path: "ui/src/pages/IssueDetail.tsx", + workspace: "auto", + browseFolderPath: null, + browseQuery: null, + }); + }); + + it("groups nested paths into collapsible folder rows instead of repeating full paths", () => { + useQueryMock.mockReturnValue( + ok(availableResponse([ + createItem({ + title: "tweet.md", + relativePath: "videos/90-days-paperclip/tweet.md", + displayPath: "videos/90-days-paperclip/tweet.md", + }), + createItem({ + title: "90-days-paperclip-1x1.mp4", + relativePath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4", + displayPath: "videos/90-days-paperclip/out/90-days-paperclip-1x1.mp4", + previewKind: "video", + }), + ])), + ); + + renderBrowser(); + + expect(container.querySelector('[role="tree"]')).not.toBeNull(); + expect(container.textContent).toContain("videos"); + expect(container.textContent).toContain("90-days-paperclip"); + expect(container.textContent).toContain("tweet.md"); + expect(container.textContent).toContain("90-days-paperclip-1x1.mp4"); + expect(container.textContent).not.toContain("videos/90-days-paperclip/tweet.md"); + + const videosFolder = Array.from(container.querySelectorAll('button[role="treeitem"]')).find( + (el) => el.getAttribute("title") === "videos", + ); + expect(videosFolder).not.toBeUndefined(); + act(() => { + videosFolder!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(container.textContent).not.toContain("tweet.md"); + }); + + it("can render without autofocus when embedded beside a preview", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()]))); + renderBrowser(vi.fn(), { autoFocusSearch: false }); + expect(container.querySelector("input")?.hasAttribute("autofocus")).toBe(false); + expect(container.querySelector("input")?.className).toContain("max-w-full"); + }); + + it("hides source controls, folder headings, workspace labels, and timestamps", () => { + useQueryMock.mockReturnValue(ok(availableResponse([ + createItem({ + relativePath: "videos/90-days-paperclip/tweet.md", + displayPath: "videos/90-days-paperclip/tweet.md", + }), + ]))); + + renderBrowser(vi.fn(), { compact: true, autoFocusSearch: false }); + + expect(container.textContent).not.toContain("Source"); + expect(container.textContent).not.toContain("Workspace"); + expect(container.textContent).not.toContain("Recently changed"); + expect(container.textContent).not.toContain("Files in folder"); + expect(container.textContent).not.toContain("From Isolated workspace"); + expect(container.querySelector(".tabular-nums")).toBeNull(); + expect(container.textContent).toContain("videos"); + expect(container.textContent).toContain("tweet.md"); + }); + + it("marks the selected file in the tree", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()]))); + renderBrowser(vi.fn(), { selectedPath: "ui/src/pages/IssueDetail.tsx" }); + const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === "ui/src/pages/IssueDetail.tsx", + ); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + }); + + it("lists the selected file's parent folder so deep-linked files appear selected", () => { + const commandsItem = createItem({ + title: "commands.md", + relativePath: "docs/reference/cli/commands.md", + displayPath: "docs/reference/cli/commands.md", + byteSize: 8192, + }); + useQueryMock.mockReturnValue(ok(availableResponse([commandsItem]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + selectedPath: "docs/reference/cli/commands.md", + }); + + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + mode: "all", + path: "docs/reference/cli", + }); + const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === "docs/reference/cli/commands.md", + ); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + expect(container.textContent).toContain("commands.md"); + }); + + it("treats an explicit null initial folder as the workspace root", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem({ + title: "README.md", + relativePath: "README.md", + displayPath: "README.md", + })]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: null, + selectedPath: "docs/reference/cli/commands.md", + }); + + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + mode: "all", + path: null, + }); + }); + + it("preserves an initial search instead of narrowing to the selected file's parent folder", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem({ + title: "FileViewerSheet.tsx", + relativePath: "ui/src/components/FileViewerSheet.tsx", + displayPath: "ui/src/components/FileViewerSheet.tsx", + })]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialQuery: "FileViewerSheet", + selectedPath: "ui/src/components/FileViewerSheet.tsx", + }); + + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + mode: "all", + q: "FileViewerSheet", + path: null, + }); + }); + + it("does not mark an unrelated highlighted row as selected", () => { + useQueryMock.mockReturnValue(ok(availableResponse([ + createItem({ + title: "vite.config.ts", + relativePath: "ui/vite.config.ts", + displayPath: "ui/vite.config.ts", + }), + ]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + selectedPath: "docs/reference/cli/commands.md", + }); + + const unrelated = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === "ui/vite.config.ts", + ); + expect(unrelated?.getAttribute("aria-selected")).toBe("false"); + }); + + it("does not render a Recent/All toggle", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()]))); + renderBrowser(); + expect(container.textContent).not.toContain("All files"); + expect(container.textContent).not.toContain("Recent changes / All"); + }); + + it("discloses truncation in the footer", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()], true))); + renderBrowser(); + expect(container.textContent).toContain("refine the search to narrow"); + }); + + it("renders newly loaded current-folder rows after Load more", () => { + const folderPath = "ui/src/components"; + const pageZero = availableAllResponse([ + createItem({ + title: "IssueLinkQuicklook.tsx", + relativePath: `${folderPath}/IssueLinkQuicklook.tsx`, + displayPath: `${folderPath}/IssueLinkQuicklook.tsx`, + }), + ], folderPath, true, 0); + const pageOne = availableAllResponse([ + createItem({ + title: "SourceTrustBadge.tsx", + relativePath: `${folderPath}/SourceTrustBadge.tsx`, + displayPath: `${folderPath}/SourceTrustBadge.tsx`, + }), + ], folderPath, true, LIST_LIMIT); + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined; + if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne); + return ok(pageZero); + }); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + }); + + expect(container.textContent).toContain("IssueLinkQuicklook.tsx"); + expect(container.textContent).not.toContain("SourceTrustBadge.tsx"); + const loadMore = Array.from(container.querySelectorAll("button")).find( + (el) => el.textContent === "Load more from this folder", + ); + act(() => { + loadMore!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(container.textContent).toContain("SourceTrustBadge.tsx"); + }); + + it("opens the highlighted row when Enter is pressed in the search field", () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()]))); + const { onOpen } = renderBrowser(); + const input = container.querySelector("input")!; + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true })); + }); + act(() => { + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + }); + expect(onOpen).toHaveBeenCalledWith({ + path: "ui/src/pages/IssueDetail.tsx", + workspace: "auto", + browseFolderPath: null, + browseQuery: null, + }); + }); + + it("reports live search state for URL-backed browse preservation", async () => { + useQueryMock.mockReturnValue(ok(availableResponse([createItem()]))); + const onBrowseStateChange = vi.fn(); + renderBrowser(vi.fn(), { + initialFolderPath: "ui/src/components", + onBrowseStateChange, + }); + onBrowseStateChange.mockClear(); + + const input = container.querySelector("input")!; + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + act(() => { + nativeSetter?.call(input, "FileViewerSheet"); + input.dispatchEvent(new Event("input", { bubbles: true, cancelable: true })); + }); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + + expect(onBrowseStateChange).toHaveBeenLastCalledWith({ + q: "FileViewerSheet", + folderPath: "ui/src/components", + projectId: null, + workspaceId: null, + }); + }); + + it("shows the remote-workspace state without file rows", () => { + useQueryMock.mockReturnValue(ok(unavailableResponse("remote_workspace"))); + renderBrowser(); + expect(container.textContent).toContain("Remote workspace preview not supported"); + expect(container.querySelector('[role="tree"]')).toBeNull(); + }); + + it("shows the no-workspace state when the issue has no workspace", () => { + useQueryMock.mockReturnValue(ok(unavailableResponse("no_workspace"))); + renderBrowser(); + expect(container.textContent).toContain("No workspace yet"); + }); + + it("opens a result from a selected other project workspace", () => { + const contentItem = createItem({ + relativePath: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + displayPath: "Paperclip Content / content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + workspaceLabel: "Paperclip Content", + workspaceKind: "project_workspace", + workspaceId: "workspace-content", + projectId: "project-content", + projectName: "Paperclip Content", + }); + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + if (options.queryKey[0] === "projects") return ok([createProject()]); + return ok(availableResponse([contentItem])); + }); + + const { onOpen } = renderBrowser(vi.fn(), { + companyId: "company-1", + initialProjectId: "project-content", + initialWorkspaceId: "workspace-content", + }); + + expect(container.textContent).not.toContain("Other project"); + expect(container.textContent).toContain("Paperclip Content / Paperclip Content"); + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + }); + + const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === contentItem.displayPath, + )!; + act(() => { + option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onOpen).toHaveBeenCalledWith({ + path: "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/README.md", + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + browseFolderPath: null, + browseQuery: null, + }); + }); + + it("focuses an initial folder in a selected other project workspace", () => { + const folderPath = "content-os/cases/active/2026-06-06-pap-10199-bundled-skills/"; + const contentItem = createItem({ + relativePath: `${folderPath}README.md`, + displayPath: `Paperclip Content / ${folderPath}README.md`, + workspaceLabel: "Paperclip Content", + workspaceKind: "project_workspace", + workspaceId: "workspace-content", + projectId: "project-content", + projectName: "Paperclip Content", + }); + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + if (options.queryKey[0] === "projects") return ok([createProject()]); + return ok(availableResponse([contentItem])); + }); + + const { onOpen } = renderBrowser(vi.fn(), { + companyId: "company-1", + initialProjectId: "project-content", + initialWorkspaceId: "workspace-content", + initialFolderPath: folderPath, + }); + + expect(container.textContent).toContain("bundled-skills"); + expect(container.textContent).not.toContain(folderPath); + expect(container.textContent).not.toContain("Files in folder"); + const listCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[3] === "list"); + expect(listCall?.[0].queryKey[4]).toMatchObject({ + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + path: folderPath, + }); + + const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === contentItem.displayPath, + )!; + act(() => { + option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + expect(onOpen).toHaveBeenCalledWith({ + path: `${folderPath}README.md`, + workspace: "project", + projectId: "project-content", + workspaceId: "workspace-content", + browseFolderPath: folderPath, + browseQuery: null, + }); + }); + + it("opens sibling files with the current folder scope", () => { + const folderPath = "docs/cli"; + const controlPlane = createItem({ + title: "control-plane-commands.md", + relativePath: `${folderPath}/control-plane-commands.md`, + displayPath: `${folderPath}/control-plane-commands.md`, + }); + useQueryMock.mockReturnValue(ok(availableResponse([ + createItem({ + title: "setup-commands.md", + relativePath: `${folderPath}/setup-commands.md`, + displayPath: `${folderPath}/setup-commands.md`, + }), + controlPlane, + ]))); + + const { onOpen } = renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + selectedPath: `${folderPath}/setup-commands.md`, + }); + + const option = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === controlPlane.displayPath, + )!; + act(() => { + option.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + + expect(onOpen).toHaveBeenCalledWith({ + path: `${folderPath}/control-plane-commands.md`, + workspace: "auto", + browseFolderPath: folderPath, + browseQuery: null, + }); + }); + + it("scrolls the selected file into view after breadcrumb-scoped listings", () => { + const folderPath = "docs"; + const selectedPath = "docs/cli/setup-commands.md"; + const scrollIntoView = vi.fn(); + Object.defineProperty(Element.prototype, "scrollIntoView", { + configurable: true, + value: scrollIntoView, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function getBoundingClientRect(this: Element) { + if (this.getAttribute("aria-selected") === "true") { + return { top: 1086, bottom: 1114, left: 0, right: 100, width: 100, height: 28, x: 0, y: 1086, toJSON: () => ({}) }; + } + if (typeof this.className === "string" && this.className.includes("overflow-y-auto")) { + return { top: 188, bottom: 546, left: 0, right: 320, width: 320, height: 358, x: 0, y: 188, toJSON: () => ({}) }; + } + return { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }; + }); + useQueryMock.mockReturnValue(ok(availableResponse([createItem({ + title: "setup-commands.md", + relativePath: selectedPath, + displayPath: selectedPath, + })]))); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + selectedPath, + }); + + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + }); + + it("loads the selected file's ancestor folders when a parent breadcrumb is open", () => { + const selectedPath = "docs/cli/setup-commands.md"; + const docsResponse = availableResponse([ + createDirectoryItem({ + title: "cli", + relativePath: "docs/cli", + displayPath: "docs/cli/", + }), + ]); + docsResponse.query = { ...docsResponse.query, mode: "all", path: "docs" }; + const cliResponse = availableResponse([ + createItem({ + title: "setup-commands.md", + relativePath: selectedPath, + displayPath: selectedPath, + }), + ]); + cliResponse.query = { ...cliResponse.query, mode: "all", path: "docs/cli" }; + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + const query = options.queryKey[4] as { path?: string | null } | undefined; + if (query?.path === "docs/cli") return ok(cliResponse); + return ok(docsResponse); + }); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: "docs", + selectedPath, + }); + + const childListCall = useQueryMock.mock.calls.find(([options]) => options.queryKey?.[4]?.path === "docs/cli"); + expect(childListCall?.[0].queryKey[4]).toMatchObject({ path: "docs/cli", offset: 0 }); + const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === selectedPath, + ); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + expect(container.textContent).toContain("setup-commands.md"); + }); + + it("auto-pages the selected file's current folder until the selected row is loaded", async () => { + const folderPath = "ui/src/components"; + const selectedPath = `${folderPath}/WorkspaceFileBrowser.tsx`; + const pageZero = availableAllResponse([ + createItem({ + title: "ActivityCharts.tsx", + relativePath: `${folderPath}/ActivityCharts.tsx`, + displayPath: `${folderPath}/ActivityCharts.tsx`, + }), + ], folderPath, true, 0); + const pageOne = availableAllResponse([ + createItem({ + title: "SourceTrustBadge.tsx", + relativePath: `${folderPath}/SourceTrustBadge.tsx`, + displayPath: `${folderPath}/SourceTrustBadge.tsx`, + }), + ], folderPath, true, LIST_LIMIT); + const pageTwo = availableAllResponse([ + createItem({ + title: "WorkspaceFileBrowser.tsx", + relativePath: selectedPath, + displayPath: selectedPath, + }), + ], folderPath, false, LIST_LIMIT * 2); + useQueryMock.mockImplementation((options: { queryKey: readonly unknown[] }) => { + const query = options.queryKey[4] as { path?: string | null; offset?: number } | undefined; + if (query?.path === folderPath && query.offset === LIST_LIMIT) return ok(pageOne); + if (query?.path === folderPath && query.offset === LIST_LIMIT * 2) return ok(pageTwo); + return ok(pageZero); + }); + + renderBrowser(vi.fn(), { + compact: true, + autoFocusSearch: false, + initialFolderPath: folderPath, + selectedPath, + }); + + await waitForExpectation(() => { + const pageTwoCall = useQueryMock.mock.calls.find( + ([options]) => options.queryKey?.[4]?.path === folderPath && options.queryKey[4].offset === LIST_LIMIT * 2, + ); + expect(pageTwoCall?.[0].queryKey[4]).toMatchObject({ path: folderPath, offset: LIST_LIMIT * 2 }); + }); + await waitForExpectation(() => { + const selected = Array.from(container.querySelectorAll('[role="treeitem"]')).find( + (el) => el.getAttribute("title") === selectedPath, + ); + expect(selected?.getAttribute("aria-selected")).toBe("true"); + expect(container.textContent).toContain("WorkspaceFileBrowser.tsx"); + }); + }); + + it("lets breadcrumb folders navigate to parent directories", () => { + const folderPath = "content-os/cases/active/"; + useQueryMock.mockReturnValue(ok(availableResponse([createItem({ + relativePath: `${folderPath}README.md`, + displayPath: `${folderPath}README.md`, + })]))); + + renderBrowser(vi.fn(), { initialFolderPath: folderPath }); + const contentOsCrumb = Array.from(container.querySelectorAll("button")).find( + (el) => el.getAttribute("title") === "content-os", + ); + expect(contentOsCrumb).not.toBeUndefined(); + act(() => { + contentOsCrumb!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); + }); + const latestListCall = useQueryMock.mock.calls.filter(([options]) => options.queryKey?.[3] === "list").at(-1); + expect(latestListCall?.[0].queryKey[4]).toMatchObject({ path: "content-os" }); + }); +}); + +describe("describeUnavailable", () => { + it("maps reasons to copy that matches the viewer's denial voice", () => { + expect(describeUnavailable("remote_workspace").title).toBe("Remote workspace preview not supported"); + expect(describeUnavailable("no_workspace").title).toBe("No workspace yet"); + expect(describeUnavailable("no_local_workspace").title).toBe("No workspace yet"); + expect(describeUnavailable("workspace_unavailable").title).toBe("Workspace is no longer available"); + expect(describeUnavailable("archived").title).toBe("Workspace is no longer available"); + }); + + it("never leaks the raw reason code as the body", () => { + for (const reason of ["remote_workspace", "no_workspace", "workspace_unavailable", "weird_unknown"]) { + const { body } = describeUnavailable(reason); + expect(body).not.toBe(reason); + expect(body).not.toMatch(/^[a-z_]+$/); + } + }); +}); diff --git a/ui/src/components/WorkspaceFileBrowser.tsx b/ui/src/components/WorkspaceFileBrowser.tsx new file mode 100644 index 00000000..76e59555 --- /dev/null +++ b/ui/src/components/WorkspaceFileBrowser.tsx @@ -0,0 +1,1062 @@ +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import { useQueries, useQuery } from "@tanstack/react-query"; +import { AlertTriangle, ChevronDown, ChevronRight, Cloud, FileCode2, FolderOpen, Loader2, Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { fileResourcesApi } from "@/api/file-resources"; +import { projectsApi } from "@/api/projects"; +import { ApiError } from "@/api/client"; +import { queryKeys } from "@/lib/queryKeys"; +import { parseWorkspaceFileRef } from "@/lib/workspace-file-parser"; +import type { + Project, + WorkspaceFileListItem, + WorkspaceFileListFileItem, + WorkspaceFileListMode, + WorkspaceFileSelector, +} from "@paperclipai/shared"; + +type BrowserSource = "current" | "other"; + +// Hard list cap. The spec called out ~50 to keep reads cheap; 100 trades a bit +// more scan for fewer "refine to narrow" dead-ends on large trees. Footer always +// discloses truncation so the cap is never silent. +const LIST_LIMIT = 100; + +function basename(path: string): string { + const idx = path.lastIndexOf("/"); + return idx < 0 ? path : path.slice(idx + 1); +} + +function normalizeFolderPrefix(path: string | null | undefined): string { + if (!path) return ""; + const trimmed = path.replace(/^\/+/, "").replace(/\/+$/, ""); + return trimmed ? `${trimmed}/` : ""; +} + +function parentFolderPath(path: string | null | undefined): string | null { + const trimmed = path?.replace(/^\/+/, "").replace(/\/+$/, ""); + if (!trimmed) return null; + const index = trimmed.lastIndexOf("/"); + return index > 0 ? trimmed.slice(0, index) : null; +} + +function folderKey(path: string | null | undefined): string { + return path?.replace(/^\/+/, "").replace(/\/+$/, "") ?? ""; +} + +function selectedAncestorFolders(selectedPath: string | null | undefined, rootPath: string | null | undefined): string[] { + const parent = parentFolderPath(selectedPath); + if (!parent) return []; + const root = folderKey(rootPath); + const parentSegments = parent.split("/").filter(Boolean); + const rootSegments = root ? root.split("/").filter(Boolean) : []; + if (rootSegments.some((segment, index) => parentSegments[index] !== segment)) return []; + const paths: string[] = []; + for (let index = rootSegments.length; index < parentSegments.length; index += 1) { + paths.push(parentSegments.slice(0, index + 1).join("/")); + } + return paths; +} + +/** + * Maps a server `unavailableReason` to a calm, board-readable explanation. + * Copy is kept in sync with `describeDenial` in FileViewerSheet so the browse + * states and the viewer's error panels read in one voice. Substring matching + * keeps it resilient to small reason-string changes on the server. + */ +export function describeUnavailable(reason: string): { title: string; body: string; icon: ReactNode } { + const lower = reason.toLowerCase(); + if (lower.includes("remote")) { + return { + icon: