mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Managed worktree services run isolated Paperclip instances with cloned databases. > - A reachable service was reported as ready even when its database, runtime identity, or login path was not usable. > - The first candidate added verified database seeding and managed repair in #11665. > - This pull request consolidates that candidate with signed login handoff and a complete readiness contract. > - Post-QA fixes close five defects in repair identity, repair responses, UI retry, seed journal handling, and seed-source trust. > - The benefit is a workspace that either opens safely or reports one accurate recovery action. ## Linked Issues or Issue Description No public GitHub issue exists for this work, so the problem is described here. **What happened** Managed workspace URLs could return HTTP 200 and report ready while login failed. QA also found cases where repair used the wrong instance identity, returned a generic error, left the UI stuck, rejected a safe journal lag, or trusted a mutable workspace manifest. **Expected behavior** Opening a ready workspace signs the board user in to the correct isolated instance. Provisioning and repair use a registered source and report a structured recovery state. **Actual behavior** Entry depended on a password copied into the clone. Several failure paths could publish stale readiness, hide the repair precondition, or trust state that the workspace could modify. **Additional context** This pull request includes the commits first published in #11665. That pull request keeps the original base head for review history. This consolidated pull request is the merge candidate. Related open readiness work includes #11575 and #11621. ## What Changed - Adds a short-lived, signed, single-use login ticket. It binds the user, workspace, instance, and runtime origin. - Exchanges the ticket through Better Auth. It creates the session and cookie through the supported adapter path. - Adds protected workspace readiness fields for the database, clone data, login handoff, seed phase, and runtime identity. - Fails readiness closed when the guest has no company or execution-workspace binding. - Binds ticket issuance to the exact cloned user and active company membership selected for the handoff. - Verifies every current active board identity through the exact-user handoff before publication or reuse. - Gates managed runtime publication on the readiness contract and the recorded worktree instance identity. - Refreshes runtime work products from the live runtime row after a port change. - Adds one workspace access card with ready, degraded, repairing, and failed states. - Uses the runtime response identity for repair. It returns structured repair precondition errors. - Lets a valid source journal lag converge during provisioning. - Binds seed and repair manifests to a source registered outside the agent-writable worktree. - Clears recovered UI errors so a successful retry can open the workspace. - Makes runtime tests register canonical sources and avoid ports owned by live host listeners. - Keeps Vitest on source suites when compiled `dist` trees exist. - Isolates CLI and adapter tests from ambient AWS and runtime API environment variables. - Preserves a 404 response for cross-company workspace ID lookups before runtime authorization. - Makes concurrent single-flight coverage independent of path-canonicalization scheduling order. ## Verification The following checks passed on the integrated head: ```sh pnpm -r typecheck pnpm build pnpm check:token-gates pnpm --filter @paperclipai/db check:migrations ``` - The server source lane passed 420 files and 4,953 tests. Five tests were skipped. - The CLI lane passed 57 files and 385 tests. - The database lane passed 26 files and 97 tests. - The shared package passed 58 files and 506 tests. - The adapter utility lane passed 640 tests. Four tests were skipped. - The Claude adapter passed 220 tests. One test was skipped. - The Codex adapter passed 323 tests. - The OpenClaw adapter passed 13 tests. - The OpenCode adapter passed 42 tests. - The plugin SDK passed 45 tests. - The workspace runtime suite passed 124 tests. - The caller-scoped readiness and handoff suite passed 52 tests. - The workspace provisioning shell suite passed 7 tests. - The runtime exposure suite passed 17 tests while live host mappings occupied fixed test ports. - `git diff --check` passed and the worktree is clean. The serialized route lane will run in GitHub CI with its normal shards. No deployment or active-workspace migration was performed. ## Risks - This is a medium-risk authentication and runtime-readiness change. - The login ticket uses exact origin, workspace, instance, and user binding. It has a short expiry and a one-time nonce. - Runtime publication is stricter. A real readiness, identity, per-user handoff, or control-plane database disagreement now blocks publication. - This pull request supersedes #11665 as the merge candidate. Close #11665 after this pull request merges. - No new database migration is included. The lockfile and workflow files are unchanged. - Deployment and active-workspace migration are intentionally outside this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Claude Opus 5 (`claude-opus-5[1m]`), 1M context, extended thinking, tool use, and code execution produced the main candidate. OpenAI GPT-5 (`gpt-5`) through Codex, with agentic reasoning, tool use, and code execution, integrated the post-QA fixes and hardened the test gates. The Codex context-window size was not exposed. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
158 lines
6.6 KiB
TypeScript
158 lines
6.6 KiB
TypeScript
import type {
|
|
ExecutionWorkspace,
|
|
ExecutionWorkspaceSummary,
|
|
ExecutionWorkspaceStatus,
|
|
ExecutionWorkspaceCloseReadiness,
|
|
WorkspaceOverviewResponse,
|
|
WorkspaceLoginHandoffTicketResponse,
|
|
WorkspaceOperation,
|
|
WorkspaceRuntimeControlTarget,
|
|
} from "@paperclipai/shared";
|
|
import { api } from "./client";
|
|
import { sanitizeWorkspaceRuntimeControlTarget } from "./workspace-runtime-control";
|
|
|
|
type WorkspaceOverviewFilters = {
|
|
projectId?: string;
|
|
status?: ExecutionWorkspaceStatus[];
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
function normalizeWorkspaceOverview(response: WorkspaceOverviewResponse): WorkspaceOverviewResponse {
|
|
return {
|
|
...response,
|
|
items: response.items.map((item) => ({
|
|
...item,
|
|
lastUpdatedAt: new Date(item.lastUpdatedAt),
|
|
primaryService: item.primaryService
|
|
? {
|
|
...item.primaryService,
|
|
updatedAt: new Date(item.primaryService.updatedAt),
|
|
}
|
|
: null,
|
|
linkedIssues: item.linkedIssues.map((issue) => ({
|
|
...issue,
|
|
updatedAt: new Date(issue.updatedAt),
|
|
})),
|
|
})),
|
|
};
|
|
}
|
|
|
|
export const executionWorkspacesApi = {
|
|
listOverview: async (companyId: string, filters?: WorkspaceOverviewFilters) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.status?.length) params.set("status", filters.status.join(","));
|
|
if (filters?.limit !== undefined) params.set("limit", String(filters.limit));
|
|
if (filters?.offset !== undefined) params.set("offset", String(filters.offset));
|
|
const qs = params.toString();
|
|
const response = await api.get<WorkspaceOverviewResponse>(
|
|
`/companies/${companyId}/workspace-overview${qs ? `?${qs}` : ""}`,
|
|
);
|
|
return normalizeWorkspaceOverview(response);
|
|
},
|
|
listSummaries: (
|
|
companyId: string,
|
|
filters?: {
|
|
projectId?: string;
|
|
projectWorkspaceId?: string;
|
|
issueId?: string;
|
|
status?: string;
|
|
reuseEligible?: boolean;
|
|
},
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
|
if (filters?.issueId) params.set("issueId", filters.issueId);
|
|
if (filters?.status) params.set("status", filters.status);
|
|
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
|
params.set("summary", "true");
|
|
const qs = params.toString();
|
|
return api.get<ExecutionWorkspaceSummary[]>(
|
|
`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`,
|
|
);
|
|
},
|
|
list: (
|
|
companyId: string,
|
|
filters?: {
|
|
projectId?: string;
|
|
projectWorkspaceId?: string;
|
|
issueId?: string;
|
|
status?: string;
|
|
reuseEligible?: boolean;
|
|
},
|
|
) => {
|
|
const params = new URLSearchParams();
|
|
if (filters?.projectId) params.set("projectId", filters.projectId);
|
|
if (filters?.projectWorkspaceId) params.set("projectWorkspaceId", filters.projectWorkspaceId);
|
|
if (filters?.issueId) params.set("issueId", filters.issueId);
|
|
if (filters?.status) params.set("status", filters.status);
|
|
if (filters?.reuseEligible) params.set("reuseEligible", "true");
|
|
const qs = params.toString();
|
|
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
|
|
},
|
|
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
|
|
getCloseReadiness: (id: string) =>
|
|
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
|
|
listWorkspaceOperations: (id: string) =>
|
|
api.get<WorkspaceOperation[]>(`/execution-workspaces/${id}/workspace-operations`),
|
|
controlRuntimeServices: (
|
|
id: string,
|
|
action: "start" | "stop" | "restart",
|
|
target: WorkspaceRuntimeControlTarget = {},
|
|
) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-services/${action}`,
|
|
sanitizeWorkspaceRuntimeControlTarget(target),
|
|
),
|
|
controlRuntimeCommands: (
|
|
id: string,
|
|
action: "start" | "stop" | "restart" | "run",
|
|
target: WorkspaceRuntimeControlTarget = {},
|
|
) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-commands/${action}`,
|
|
sanitizeWorkspaceRuntimeControlTarget(target),
|
|
),
|
|
repair: (id: string) =>
|
|
api.post<{ workspace: ExecutionWorkspace; operation: WorkspaceOperation }>(
|
|
`/execution-workspaces/${id}/runtime-commands/repair`,
|
|
{},
|
|
),
|
|
/**
|
|
* Mint a single-use workspace login handoff (PAP-17572).
|
|
*
|
|
* The returned URL carries a short-lived ticket, so the caller must navigate to
|
|
* it rather than store or share it. The server answers the navigation with an
|
|
* HTTP redirect, which is what keeps the ticket out of browser history.
|
|
*/
|
|
requestLoginHandoff: (id: string, next?: string) =>
|
|
api.post<WorkspaceLoginHandoffTicketResponse>(
|
|
`/execution-workspaces/${id}/login-handoff`,
|
|
next ? { next } : {},
|
|
),
|
|
update: (id: string, data: Record<string, unknown>) => api.patch<ExecutionWorkspace>(`/execution-workspaces/${id}`, data),
|
|
/**
|
|
* Reconcile a git-worktree branch divergence via the S4 (`PAP-1586`) op.
|
|
*
|
|
* Hits `POST /execution-workspaces/:id/reconcile-branch`. That route is the reviewed,
|
|
* OpenAPI-documented backend contract and already ships on `master`: it was merged ahead of this
|
|
* client change in `server/src/routes/execution-workspaces.ts` (route registration:
|
|
* `router.post("/execution-workspaces/:id/reconcile-branch", ...)`, landed in PR #9170, with the
|
|
* `forward` auto-reconcile path in PR #9172). This client is therefore additive against an
|
|
* existing endpoint, not a call to a missing one. Keep this path byte-identical to the backend
|
|
* route; the drift is pinned by a regression test in `execution-workspaces.test.ts`.
|
|
* - `mode: "forward"` — server re-verifies `ancestryVerdict === "ancestor"` (client hint is
|
|
* never trusted); no `reason` needed.
|
|
* - `mode: "override"` — audited break-glass; the server rejects agent actors, re-checks
|
|
* `runtime:manage` permission, and requires a non-empty operator `reason`.
|
|
* - `mode: "quarantine_restore"` — lossless dirty-worktree repair; the server quarantines the
|
|
* dirty changes onto a rescue branch and restores the recorded branch. No `reason` needed.
|
|
*/
|
|
reconcile: (
|
|
id: string,
|
|
body: { mode: "forward" } | { mode: "override"; reason: string } | { mode: "quarantine_restore" },
|
|
) => api.post<ExecutionWorkspace>(`/execution-workspaces/${id}/reconcile-branch`, body),
|
|
};
|