mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01: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>
225 lines
8.6 KiB
TypeScript
225 lines
8.6 KiB
TypeScript
import { and, desc, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { issueWorkProducts, workspaceRuntimeServices } from "@paperclipai/db";
|
|
import type { IssueWorkProduct } from "@paperclipai/shared";
|
|
import { insertRowsInChunks } from "./batch-insert.js";
|
|
import type { ImportIssueWorkProductRow } from "./import-write-types.js";
|
|
|
|
type IssueWorkProductRow = typeof issueWorkProducts.$inferSelect;
|
|
|
|
function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
|
|
return {
|
|
id: row.id,
|
|
companyId: row.companyId,
|
|
projectId: row.projectId ?? null,
|
|
issueId: row.issueId,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
runtimeServiceId: row.runtimeServiceId ?? null,
|
|
type: row.type as IssueWorkProduct["type"],
|
|
provider: row.provider,
|
|
externalId: row.externalId ?? null,
|
|
title: row.title,
|
|
url: row.url ?? null,
|
|
status: row.status,
|
|
reviewState: row.reviewState as IssueWorkProduct["reviewState"],
|
|
isPrimary: row.isPrimary,
|
|
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
|
|
summary: row.summary ?? null,
|
|
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
|
sourceTrust: row.sourceTrust ?? null,
|
|
createdByRunId: row.createdByRunId ?? null,
|
|
createdAt: row.createdAt,
|
|
updatedAt: row.updatedAt,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh runtime-service work products from the live runtime rows they point at
|
|
* (PAP-17572).
|
|
*
|
|
* A runtime URL is only valid for as long as the process holds that port. A
|
|
* managed restart can relocate it, which used to leave a user-facing preview link
|
|
* that answers with somebody else's service or nothing at all. The runtime row is
|
|
* the authoritative publication record, so it wins over the stored copy.
|
|
*
|
|
* Read-path only and deliberately non-destructive: a work product whose runtime
|
|
* row is gone keeps its recorded URL and is reported unhealthy rather than
|
|
* silently blanked, so the history of what was published survives.
|
|
*/
|
|
export function reconcileRuntimeServiceWorkProducts(
|
|
products: IssueWorkProduct[],
|
|
liveRuntimeServices: Array<{
|
|
id: string;
|
|
url: string | null;
|
|
status: string;
|
|
healthStatus: string;
|
|
}>,
|
|
): IssueWorkProduct[] {
|
|
if (products.length === 0) return products;
|
|
const liveById = new Map(liveRuntimeServices.map((service) => [service.id, service]));
|
|
return products.map((product) => {
|
|
if (product.type !== "runtime_service" || !product.runtimeServiceId) return product;
|
|
const live = liveById.get(product.runtimeServiceId);
|
|
if (!live) {
|
|
return product.healthStatus === "unhealthy" ? product : { ...product, healthStatus: "unhealthy" };
|
|
}
|
|
const isServing = live.status === "running" && live.healthStatus === "healthy";
|
|
const url = live.url ?? product.url;
|
|
const healthStatus: IssueWorkProduct["healthStatus"] = isServing ? "healthy" : "unhealthy";
|
|
if (product.url === url && product.healthStatus === healthStatus) return product;
|
|
return { ...product, url, healthStatus };
|
|
});
|
|
}
|
|
|
|
export function workProductService(db: Db) {
|
|
return {
|
|
listForIssue: async (issueId: string) => {
|
|
const rows = await db
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.issueId, issueId))
|
|
.orderBy(desc(issueWorkProducts.isPrimary), desc(issueWorkProducts.updatedAt));
|
|
const products = rows.map(toIssueWorkProduct);
|
|
const runtimeServiceIds = products
|
|
.map((product) => (product.type === "runtime_service" ? product.runtimeServiceId : null))
|
|
.filter((value): value is string => Boolean(value));
|
|
if (runtimeServiceIds.length === 0) return products;
|
|
const liveRuntimeServices = await db
|
|
.select({
|
|
id: workspaceRuntimeServices.id,
|
|
url: workspaceRuntimeServices.url,
|
|
status: workspaceRuntimeServices.status,
|
|
healthStatus: workspaceRuntimeServices.healthStatus,
|
|
})
|
|
.from(workspaceRuntimeServices)
|
|
.where(inArray(workspaceRuntimeServices.id, [...new Set(runtimeServiceIds)]));
|
|
return reconcileRuntimeServiceWorkProducts(products, liveRuntimeServices);
|
|
},
|
|
|
|
getById: async (id: string) => {
|
|
const row = await db
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
return row ? toIssueWorkProduct(row) : null;
|
|
},
|
|
|
|
createForIssue: async (issueId: string, companyId: string, data: Omit<typeof issueWorkProducts.$inferInsert, "issueId" | "companyId">) => {
|
|
const row = await db.transaction(async (tx) => {
|
|
if (data.isPrimary) {
|
|
await tx
|
|
.update(issueWorkProducts)
|
|
.set({ isPrimary: false, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(issueWorkProducts.companyId, companyId),
|
|
eq(issueWorkProducts.issueId, issueId),
|
|
eq(issueWorkProducts.type, data.type),
|
|
),
|
|
);
|
|
}
|
|
return await tx
|
|
.insert(issueWorkProducts)
|
|
.values({
|
|
...data,
|
|
companyId,
|
|
issueId,
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
});
|
|
return row ? toIssueWorkProduct(row) : null;
|
|
},
|
|
|
|
update: async (id: string, patch: Partial<typeof issueWorkProducts.$inferInsert>) => {
|
|
const row = await db.transaction(async (tx) => {
|
|
const existing = await tx
|
|
.select()
|
|
.from(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!existing) return null;
|
|
|
|
if (patch.isPrimary === true) {
|
|
await tx
|
|
.update(issueWorkProducts)
|
|
.set({ isPrimary: false, updatedAt: new Date() })
|
|
.where(
|
|
and(
|
|
eq(issueWorkProducts.companyId, existing.companyId),
|
|
eq(issueWorkProducts.issueId, existing.issueId),
|
|
eq(issueWorkProducts.type, existing.type),
|
|
),
|
|
);
|
|
}
|
|
|
|
return await tx
|
|
.update(issueWorkProducts)
|
|
.set({ ...patch, updatedAt: new Date() })
|
|
.where(eq(issueWorkProducts.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
});
|
|
return row ? toIssueWorkProduct(row) : null;
|
|
},
|
|
|
|
/**
|
|
* Batched work-product insert for company import.
|
|
*
|
|
* {@link createForIssue} clears the prior primary of the same type on every
|
|
* call; imported issues are brand new, so the only primaries in play are the
|
|
* imported rows themselves. We reproduce "last primary wins" within each
|
|
* (issue, type) group and insert the whole batch in chunked statements.
|
|
*/
|
|
createManyForImport: async (rows: ImportIssueWorkProductRow[]): Promise<void> => {
|
|
if (rows.length === 0) return;
|
|
const lastPrimaryIndexByGroup = new Map<string, number>();
|
|
rows.forEach((row, index) => {
|
|
if (row.isPrimary) lastPrimaryIndexByGroup.set(`${row.issueId}:${row.type}`, index);
|
|
});
|
|
const values = rows.map((row, index) => ({
|
|
companyId: row.companyId,
|
|
issueId: row.issueId,
|
|
projectId: row.projectId ?? null,
|
|
type: row.type,
|
|
provider: row.provider,
|
|
externalId: row.externalId ?? null,
|
|
title: row.title,
|
|
url: row.url ?? null,
|
|
status: row.status,
|
|
reviewState: row.reviewState,
|
|
isPrimary: row.isPrimary
|
|
? lastPrimaryIndexByGroup.get(`${row.issueId}:${row.type}`) === index
|
|
: false,
|
|
healthStatus: row.healthStatus,
|
|
summary: row.summary ?? null,
|
|
metadata: row.metadata ?? null,
|
|
executionWorkspaceId: row.executionWorkspaceId ?? null,
|
|
runtimeServiceId: row.runtimeServiceId ?? null,
|
|
createdByRunId: row.createdByRunId ?? null,
|
|
sourceTrust: row.sourceTrust ?? null,
|
|
}));
|
|
// Chunked writes are wrapped in a single transaction so a large import
|
|
// that spans multiple insert statements is atomic: if a later chunk
|
|
// fails, the earlier chunks roll back rather than leaving a partial
|
|
// prefix behind (which a retry would then duplicate). Mirrors the
|
|
// per-writer transaction the batched issue/document writers use.
|
|
await db.transaction(async (tx) => {
|
|
await insertRowsInChunks(tx, issueWorkProducts, values);
|
|
});
|
|
},
|
|
|
|
remove: async (id: string) => {
|
|
const row = await db
|
|
.delete(issueWorkProducts)
|
|
.where(eq(issueWorkProducts.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
return row ? toIssueWorkProduct(row) : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
export { toIssueWorkProduct };
|